From 95df1b6257f8580ae300d063e4751eef06a05f46 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 28 Jul 2026 21:57:50 +0800 Subject: [PATCH 01/35] =?UTF-8?q?feat(notify):=20user-notify=20=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E9=80=9A=E7=9F=A5=E4=B8=8E=E7=82=B9=E5=87=BB=E6=B7=B1?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用社区 user-notify 替换官方插件发送路径,支持点击跳转;补齐分类开关、免打扰、stalled/quota 事件与 Dock badge。 --- src-tauri/Cargo.lock | 549 +++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 + src-tauri/src/lib.rs | 7 + src-tauri/src/os_notify.rs | 225 +++++++++++ src/i18n/en.ts | 16 +- src/i18n/zh.ts | 14 +- src/lib/api.ts | 12 + src/lib/notifications.ts | 367 ++++++++++++------ src/lib/notificationsCore.ts | 407 ++++++++++++++++++++ src/lib/notifyPrefs.ts | 16 + src/lib/storageKeys.ts | 2 + src/nav/SettingsDialog.tsx | 67 +++- src/state/store.tsx | 62 ++- tests/frontend/notifications.test.ts | 300 +++++++++++++++ 15 files changed, 1919 insertions(+), 129 deletions(-) create mode 100644 src-tauri/src/os_notify.rs create mode 100644 src/lib/notificationsCore.ts create mode 100644 src/lib/notifyPrefs.ts create mode 100644 tests/frontend/notifications.test.ts 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/lib.rs b/src-tauri/src/lib.rs index 1edc4669..69b48d36 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ mod power; /// 派生并发上限都挂靠此接口(单一口径 `is_ours`,计数==孤儿判定)。 pub mod proc_registry; mod process_quota; +mod os_notify; pub mod profile; /// issue #112 本地资源仪表盘:只读聚合 proc_registry / process_quota / /// session_gate 已有的采样,不新增采样、不碰任何安全网写路径。 @@ -235,6 +236,9 @@ pub fn run() { host::automerge::spawn_pr_automerge_watch(app.handle().clone()); power::spawn_sweep(app.handle().clone()); process_quota::spawn_monitor(app.handle().clone()); + if let Err(err) = os_notify::init(app.handle()) { + eprintln!("[weft] os_notify init: {err}"); + } gc::spawn_periodic(app.handle().clone()); skills::spawn_periodic(app.handle().clone()); im::spawn(app.handle().clone()); @@ -311,6 +315,9 @@ pub fn run() { commands::set_guardrails, process_quota::process_quota_status, resource_dashboard::resource_dashboard_snapshot, + os_notify::os_notify_permission, + os_notify::os_notify_request_permission, + os_notify::os_notify_send, commands::session_for, commands::session_meta, commands::effective_config, diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs new file mode 100644 index 00000000..94a7f751 --- /dev/null +++ b/src-tauri/src/os_notify.rs @@ -0,0 +1,225 @@ +//! Desktop OS notifications via the community `user-notify` crate. +//! +//! Official `@tauri-apps/plugin-notification` cannot deliver a reliable click +//! callback on desktop (plugins-workspace#2150). `user-notify` talks to the +//! platform APIs directly and returns `NotificationResponse` with `user_info`, +//! which we bridge to the frontend as `notify://open` for deep-linking. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use user_notify::{ + get_notification_manager, NotificationBuilder, NotificationManager, NotificationResponse, + NotificationResponseAction, +}; + +/// Stable app id — matches `tauri.conf.json` `identifier`. +const APP_ID: &str = "com.weft.app"; + +/// Event the frontend listens for after the user clicks a notification. +pub const OPEN_EVENT: &str = "notify://open"; + +static MANAGER: OnceLock> = OnceLock::new(); + +fn manager() -> Result<&'static Arc, String> { + MANAGER + .get() + .ok_or_else(|| "os_notify not initialized".to_string()) +} + +/// Payload the frontend stores in `user_info` and gets back on click. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[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 ask_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: 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 ask_id: Option, + #[serde(default)] + pub workspace_id: Option, +} + +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()), + ask_id: info.get("askId").and_then(|s| s.parse().ok()), + workspace_id: info.get("workspaceId").and_then(|s| s.parse().ok()), + } +} + +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.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()); + } + info +} + +fn focus_main_window(app: &AppHandle) { + if let Some(win) = app.get_webview_window("main") { + let _ = win.unminimize(); + let _ = win.show(); + let _ = win.set_focus(); + } +} + +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; + } + focus_main_window(&app); + let payload = payload_from_user_info(&response.user_info); + if let Err(err) = app.emit(OPEN_EVENT, payload) { + 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(); + // Mock manager (unsigned macOS dev) always reports granted; real macOS + // distinguishes NotDetermined as false without a dedicated "prompt" API, so + // we map false → "prompt" and let requestPermission settle it. + match mgr.get_notification_permission_state().await { + Ok(true) => Ok("granted".to_string()), + Ok(false) => 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) => Ok("granted".to_string()), + Ok(false) => 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(()) +} + +#[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), + ask_id: Some(99), + workspace_id: Some(3), + }; + 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.ask_id, Some(99)); + assert_eq!(payload.workspace_id, Some(3)); + } + + #[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, + ask_id: None, + workspace_id: None, + }; + assert!(user_info_from_req(&req).is_empty()); + } +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e00c092e..9c4fc76a 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,16 @@ 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", + 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..429f5f74 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,16 @@ export const zh = { skillsOverridden: "已被 {{source}} 覆盖", skillsRemove: "删除来源", notifications: "系统通知", - notificationsHint: "窗口不在前台时,新的待办与进入 review 的任务会弹系统通知。", + notificationsHint: "窗口不在前台时,按下方勾选的事件弹出系统通知;点击通知可跳转到对应位置。Dock 角标始终反映待你处理数量。", notifyDenied: "系统已拒绝通知 — 打开系统设置开启", + 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..e598e920 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -361,6 +361,18 @@ 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"), + osNotifySend: (req: { + title: string; + body: string; + kind: string; + threadId?: number | null; + directionId?: number | null; + askId?: number | null; + workspaceId?: number | 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..9ca9619a 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,41 +1,57 @@ import { useEffect, useRef, useState } from "react"; -import { - isPermissionGranted, - requestPermission, - sendNotification, -} from "@tauri-apps/plugin-notification"; 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 { + badgeCountFrom, + diffForNotifications, + isAppInForeground, + isInQuietHours, + notifyCopyKeys, + planNotifyOpen, + snapshotOf, + 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()); + if (p === "granted") return "granted"; + // user-notify collapses Denied + NotDetermined to false. Prefer the Web + // Notification permission bit when present so Settings can show the + // "open System Settings" recovery instead of re-prompting forever. + 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). */ +/** Resolve to a settled state, asking the OS only from "prompt". */ 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"; + return asNotifyPermission(await api.osNotifyRequestPermission()); } catch { return "denied"; } @@ -45,11 +61,12 @@ export async function ensureNotifyPermission(): Promise { * Linux has no portable one — returns false and the caller's copy stands. */ 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,85 +76,135 @@ 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. - */ - -/** Notify-relevant state reduced to stable identity keys → context line. */ -export interface NotifySnapshot { - needs: Map; - review: Map; +async function setDockBadge(count: number): Promise { + try { + const win = getCurrentWindow(); + await win.setBadgeCount(count > 0 ? count : undefined); + } catch { + /* pure-vite / unsupported platform */ + } } -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}`); - } - for (const a of asks) { - n.set(`ask:${a.id}`, `${a.thread_title} · ${a.dir_name}`); - } - for (const w of triggers) { - n.set(`wt:${w.thread_id}:${w.index}`, `${w.thread_title} · ${w.name}`); +async function requestAttention(): Promise { + try { + const win = getCurrentWindow(); + await win.requestUserAttention(UserAttentionType.Informational); + } catch { + /* pure-vite / unsupported */ } - 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); - }); +} + +async function focusMainWindow(): Promise { + try { + const win = getCurrentWindow(); + await win.unminimize(); + await win.show(); + await win.setFocus(); + } catch { + /* pure-vite */ } - 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; +export interface OsNotifyOpenEvent { + kind: string; + threadId?: number | null; + directionId?: number | null; + askId?: number | null; + workspaceId?: number | null; } -/** 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] }); +/** 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) => Promise; + openNeeds: () => void; + openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; + activeWorkspaceId: number | null; + }, +): Promise { + await focusMainWindow(); + const intents = planNotifyOpen(payload); + for (const intent of intents) { + if (intent.type === "workspace") { + if (intent.workspaceId !== deps.activeWorkspaceId) { + await deps.selectWorkspace(intent.workspaceId); + } + continue; + } + if (intent.type === "resources") { + deps.openSettings("resources"); + continue; + } + if (intent.type === "direction") { + await deps.goToDirectionRef(intent.threadId, intent.direction); + continue; + } + if (intent.type === "needs") { + deps.openNeeds(); } } - return out; } -const OVERVIEW_POLL_MS = 10_000; +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, + askId: route.askId ?? null, + workspaceId: route.workspaceId ?? 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, + overview, + sessions, + leadTurn, + processQuota, + threads, + notifyEnabled, + notifyCategories, + quietHours, + activeWorkspaceId, + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + } = useStore(); const { t } = useTranslation(); - const [overview, setOverview] = useState([]); const prev = useRef(null); const baselineWs = useRef(null); const granted = useRef(null); + 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>({}); + useEffect(() => { + const m: Record = {}; + for (const th of threads) m[th.id] = { title: th.title }; + threadsById.current = m; + }, [threads]); + + // OS permission, settled once per enable. useEffect(() => { if (!notifyEnabled) return; void ensureNotifyPermission().then((p) => { @@ -145,51 +212,127 @@ export function useSystemNotifications() { }); }, [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 }) => { + setWindowFocused(payload); + }); } catch { - /* backend unavailable */ + if (!cancelled) setWindowFocused(null); } + })(); + return () => { + cancelled = true; + unFocus?.(); }; - void tick(); - const h = setInterval(tick, OVERVIEW_POLL_MS); + }, []); + + // Click / action deep-link from the native bridge. + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + void (async () => { + try { + unlisten = await listen("notify://open", (event) => { + void handleNotifyOpen(event.payload, { + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + activeWorkspaceId, + }); + }); + } catch { + /* pure-vite */ + } + if (cancelled) unlisten?.(); + })(); return () => { - alive = false; - clearInterval(h); + cancelled = true; + unlisten?.(); }; - }, [notifyEnabled, activeWorkspaceId]); + }, [ + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + activeWorkspaceId, + ]); + // Dock / taskbar badge tracks actionable Needs-you count. useEffect(() => { - const next = snapshotOf(needs, asks, writeTriggers, overview); + const count = badgeCountFrom(needs, asks, writeTriggers); + if (lastBadge.current === count) return; + lastBadge.current = count; + void setDockBadge(count); + }, [needs, asks, writeTriggers]); + + useEffect(() => { + const next = snapshotOf( + needs, + asks, + writeTriggers, + overview, + sessions, + leadTurn, + processQuota, + threadsById.current, + activeWorkspaceId, + ); 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)) { - 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, - }), - }); - } catch { - /* never let a failed ping disturb the app */ - } + if ( + isAppInForeground({ + windowFocused, + documentFocused: document.hasFocus(), + }) + ) { + return; } - }, [needs, asks, writeTriggers, overview, notifyEnabled, activeWorkspaceId, t]); + if (isInQuietHours(quietHours)) return; + + const events = diffForNotifications(base, next, notifyCategories); + 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, + overview, + sessions, + leadTurn, + processQuota, + notifyEnabled, + notifyCategories, + quietHours, + activeWorkspaceId, + windowFocused, + t, + ]); } diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts new file mode 100644 index 00000000..eb0503fe --- /dev/null +++ b/src/lib/notificationsCore.ts @@ -0,0 +1,407 @@ +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; + +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; + threadId: number; +} + +/** Deep-link fields carried through the OS notification `user_info`. */ +export interface NotifyRoute { + kind: NotifyCategory; + threadId?: number; + directionId?: number; + askId?: number; + workspaceId?: number; +} + +/** 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: ThreadOverview[], + sessions: Record, + leadTurn: Record, + processQuota: ProcessQuotaStatus | null, + threadsById: Record = {}, + workspaceId: number | null = null, +): NotifySnapshot { + const n = new Map(); + for (const it of needs) { + // Notices are display-only (or self-clearing); only real questions ping Needs-you. + if (it.kind !== "question") continue; + 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: workspaceId ?? undefined, + }, + }); + } + for (const a of asks) { + const directionId = Number(a.dir); + 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: workspaceId ?? undefined, + }, + }); + } + for (const w of triggers) { + n.set(`wt:${w.thread_id}:${w.index}`, { + sample: `${w.thread_title} · ${w.name}`, + route: { + kind: "needs", + threadId: w.thread_id, + workspaceId: workspaceId ?? undefined, + }, + }); + } + + const r = new Map(); + for (const o of overview) { + o.statuses.forEach((s, i) => { + if (s !== "review") return; + const directionId = o.direction_ids[i]; + r.set(`rev:${directionId}`, { + sample: o.title, + route: { + kind: "review", + threadId: o.thread_id, + directionId, + workspaceId: workspaceId ?? undefined, + }, + }); + }); + } + + const stalled = new Map(); + for (const s of Object.values(sessions)) { + if (s.status !== "stalled") continue; + const title = threadsById[s.threadId]?.title ?? `thread ${s.threadId}`; + stalled.set(`stall:worker:${s.info.session_id}`, { + sample: `${title} · dir ${s.directionId}`, + route: { + kind: "stalled", + threadId: s.threadId, + directionId: s.directionId, + workspaceId: workspaceId ?? undefined, + }, + }); + } + for (const [tidStr, turn] of Object.entries(leadTurn)) { + if (turn.state !== "stalled") continue; + const tid = Number(tidStr); + const title = threadsById[tid]?.title ?? `thread ${tid}`; + stalled.set(`stall:lead:${tid}`, { + sample: title, + route: { + kind: "stalled", + threadId: tid, + workspaceId: workspaceId ?? undefined, + }, + }); + } + + 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", + workspaceId: workspaceId ?? undefined, + }, + }); + } + + 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").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 } + | { type: "needs" } + | { type: "resources" }; + +export function planNotifyOpen(payload: { + kind: string; + threadId?: number | null; + directionId?: number | null; + workspaceId?: number | null; +}): NotifyOpenIntent[] { + const out: NotifyOpenIntent[] = []; + if (payload.workspaceId != null) { + out.push({ type: "workspace", workspaceId: payload.workspaceId }); + } + if (payload.kind === "quota") { + out.push({ type: "resources" }); + return out; + } + if (payload.threadId != null) { + const direction = + payload.directionId != null ? String(payload.directionId) : "lead"; + 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/nav/SettingsDialog.tsx b/src/nav/SettingsDialog.tsx index 252a5e2c..8e5ef121 100644 --- a/src/nav/SettingsDialog.tsx +++ b/src/nav/SettingsDialog.tsx @@ -38,8 +38,12 @@ import { cn } from "../lib/cn"; import { spawnableToolsOf } from "../lib/toolStatus.ts"; import { ensureNotifyPermission, + formatQuietTime, + NOTIFY_CATEGORIES, notifyPermission, openSystemNotificationSettings, + parseQuietTime, + type NotifyCategory, type NotifyPermission, } from "../lib/notifications"; import { useStore, type SettingsPage } from "../state/store"; @@ -215,6 +219,10 @@ function GeneralSettings() { refreshDefaultTool, notifyEnabled, setNotifyEnabled, + notifyCategories, + setNotifyCategory, + quietHours, + setQuietHours, } = useStore(); const [lang, setLangState] = useState(currentLang()); @@ -379,7 +387,7 @@ function GeneralSettings() { -
+
)} + {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..bbe6e611 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -26,6 +26,15 @@ 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 QuietHours, +} from "../lib/notificationsCore"; import { fillMetaHoles, mergeSnapshot, metaFromInit, metaFromSnapshot, metaFromUsage } from "../session/sessionMeta"; import type { BusMsg, @@ -526,9 +535,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; @@ -758,8 +773,8 @@ 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", ); @@ -767,6 +782,23 @@ export function StoreProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_KEYS.notify, on ? "1" : "0"); setNotifyEnabledState(on); }, []); + 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; + }); + }, []); + 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 // on launch — its state is in-memory (same pattern as dangerous mode). @@ -2866,26 +2898,24 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, [activeThreadId]); // Needs-you: poll workspace-wide, plus a push refresh when the coordinator - // signals a new ask (needs-you://changed). Poll is the safety net; the event - // makes new questions appear near-instantly. + // signals a new ask (}, [activeWorkspaceId, refreshNeeds]); + + // 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) { - setNeeds([]); - return; - } + if (activeWorkspaceId == null) return; let alive = true; const tick = () => { - if (alive) void refreshNeeds(); + if (alive) void refreshOverview(); }; tick(); - const h = setInterval(tick, 4000); - const unChanged = listen("needs-you://changed", tick); + const h = setInterval(tick, 10_000); return () => { alive = false; clearInterval(h); - void unChanged.then((f) => f()); }; - }, [activeWorkspaceId, refreshNeeds]); + }, [activeWorkspaceId, refreshOverview]); + // Live-refresh the repo map when the curator calibrates an edge (or the auto // pass finishes) for the active workspace. @@ -3176,6 +3206,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..6b8c57bb --- /dev/null +++ b/tests/frontend/notifications.test.ts @@ -0,0 +1,300 @@ +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 non-answerable needs (stall notices stay in stalled lane)", () => { + const snap = snapshotOf( + [need({ ask_id: 1, kind: "notice" })], + [], + [], + [], + {}, + {}, + null, + ); + assert.equal(snap.needs.size, 0); +}); + +test("snapshotOf captures stalled workers and leads", () => { + const snap = snapshotOf( + [], + [], + [], + [], + { + 99: { + info: { session_id: 99 }, + status: "stalled", + directionId: 5, + threadId: 2, + }, + }, + { 2: { state: "stalled", queue: [] }, 3: { state: "busy", queue: [] } }, + null, + { 2: { title: "Issue B" } }, + ); + assert.equal(snap.stalled.get("stall:worker:99")?.sample, "Issue B · dir 5"); + assert.equal(snap.stalled.get("stall:worker:99")?.route.directionId, 5); + assert.equal(snap.stalled.get("stall:lead:2")?.sample, "Issue B"); + assert.equal(snap.stalled.has("stall:lead:3"), false); +}); + +test("snapshotOf only surfaces degraded quota keyed by transitionSeq", () => { + const snap = snapshotOf([], [], [], [], {}, {}, quota("degraded", 4)); + assert.equal(snap.quota.get("quota:degraded:4")?.sample, "900 / 1000"); + assert.equal(snap.quota.get("quota:degraded:4")?.route.kind, "quota"); + 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" })], + [ask(1)], + [wt(0), wt(1)], + ); + assert.equal(n, 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", () => { + assert.deepEqual(planNotifyOpen({ kind: "quota", workspaceId: 1 }), [ + { type: "workspace", 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" }]); +}); From 13a921a10f5bd2edad0abb47b41690d7f0bde820 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 28 Jul 2026 22:30:27 +0800 Subject: [PATCH 02/35] fix(notify): restore needs-you refresh and harden click deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复误删的 Needs-you 轮询/事件监听;补齐 write-trigger/action-required 路由、workspace 归属、stalled session 精确定位、权限 denied 记忆与冷启动 pending click。 --- src-tauri/src/lib.rs | 1 + src-tauri/src/os_notify.rs | 97 ++++++++++++++++++++++++++-- src/lib/api.ts | 14 ++++ src/lib/notifications.ts | 47 ++++++++++---- src/lib/notificationsCore.ts | 82 +++++++++++++++++++---- src/state/store.tsx | 49 ++++++++++++-- tests/frontend/notifications.test.ts | 63 ++++++++++++++++-- 7 files changed, 310 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69b48d36..5fde1a0d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -318,6 +318,7 @@ pub fn run() { os_notify::os_notify_permission, os_notify::os_notify_request_permission, os_notify::os_notify_send, + os_notify::os_notify_take_pending_open, commands::session_for, commands::session_meta, commands::effective_config, diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 94a7f751..5ef07941 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -6,7 +6,7 @@ //! which we bridge to the frontend as `notify://open` for deep-linking. use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, Runtime}; @@ -22,6 +22,10 @@ const APP_ID: &str = "com.weft.app"; pub const OPEN_EVENT: &str = "notify://open"; static MANAGER: OnceLock> = 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 @@ -29,6 +33,14 @@ fn manager() -> Result<&'static Arc, String> { .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)] #[serde(rename_all = "camelCase")] @@ -39,9 +51,15 @@ pub struct NotifyOpenPayload { #[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, } /// Frontend → backend send request. @@ -57,9 +75,23 @@ pub struct NotifySendRequest { #[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, +} + +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 { @@ -67,8 +99,11 @@ fn payload_from_user_info(info: &HashMap) -> 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)), } } @@ -83,12 +118,24 @@ fn user_info_from_req(req: &NotifySendRequest) -> HashMap { 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() }, + ); + } info } @@ -100,6 +147,12 @@ fn focus_main_window(app: &AppHandle) { } } +fn remember_settled_permission(value: &str) { + if let Ok(mut guard) = perm_settled().lock() { + *guard = Some(value.to_string()); + } +} + fn handle_response(app: AppHandle, response: NotificationResponse) { // Only deep-link on an actual click (Default) or a custom action button. // Dismiss must stay silent. @@ -113,6 +166,11 @@ fn handle_response(app: AppHandle, response: NotificationResponse } focus_main_window(&app); let payload = payload_from_user_info(&response.user_info); + // Always retain the latest pending open so a cold-start click is not lost + // if the frontend listener is not mounted yet. + if let Ok(mut guard) = pending_open().lock() { + *guard = Some(payload.clone()); + } if let Err(err) = app.emit(OPEN_EVENT, payload) { eprintln!("[weft] os_notify emit {OPEN_EVENT}: {err}"); } @@ -144,12 +202,20 @@ pub fn init(app: &AppHandle) -> Result<(), String> { /// `"granted" | "denied" | "prompt"` — mirrors the frontend `NotifyPermission`. #[tauri::command] pub async fn os_notify_permission() -> Result { + if let Ok(guard) = perm_settled().lock() { + if let Some(settled) = guard.as_ref() { + return Ok(settled.clone()); + } + } let mgr = manager()?.clone(); // Mock manager (unsigned macOS dev) always reports granted; real macOS // distinguishes NotDetermined as false without a dedicated "prompt" API, so - // we map false → "prompt" and let requestPermission settle it. + // we map false → "prompt" until a request settles to denied/granted. match mgr.get_notification_permission_state().await { - Ok(true) => Ok("granted".to_string()), + Ok(true) => { + remember_settled_permission("granted"); + Ok("granted".to_string()) + } Ok(false) => Ok("prompt".to_string()), Err(e) => Err(format!("os_notify permission: {e}")), } @@ -159,8 +225,14 @@ pub async fn os_notify_permission() -> Result { pub async fn os_notify_request_permission() -> Result { let mgr = manager()?.clone(); match mgr.first_time_ask_for_notification_permission().await { - Ok(true) => Ok("granted".to_string()), - Ok(false) => Ok("denied".to_string()), + 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}")), } } @@ -185,6 +257,12 @@ pub async fn os_notify_send(req: NotifySendRequest) -> Result<(), String> { Ok(()) } +/// Drain a click that arrived before the frontend listener was ready. +#[tauri::command] +pub fn os_notify_take_pending_open() -> Option { + pending_open().lock().ok().and_then(|mut g| g.take()) +} + #[cfg(test)] mod tests { use super::*; @@ -197,16 +275,22 @@ mod tests { 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), }; 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)); } #[test] @@ -217,8 +301,11 @@ mod tests { kind: String::new(), thread_id: None, direction_id: None, + repo_id: None, + session_id: None, ask_id: None, workspace_id: None, + open_needs: None, }; assert!(user_info_from_req(&req).is_empty()); } diff --git a/src/lib/api.ts b/src/lib/api.ts index e598e920..691f7015 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -364,14 +364,28 @@ export const api = { // Desktop OS notifications via user-notify (click deep-link capable). osNotifyPermission: () => invoke("os_notify_permission"), osNotifyRequestPermission: () => invoke("os_notify_request_permission"), + 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; + } | null>("os_notify_take_pending_open"), 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; }) => 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 → diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 9ca9619a..f34e2e13 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -109,8 +109,11 @@ 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; } /** Apply a notification click to the in-app navigation surface. */ @@ -118,7 +121,11 @@ export async function handleNotifyOpen( payload: OsNotifyOpenEvent, deps: { selectWorkspace: (id: number) => Promise | void; - goToDirectionRef: (thread: number, dir: string) => Promise; + goToDirectionRef: ( + thread: number, + dir: string, + opts?: { repoId?: number; sessionId?: number }, + ) => Promise; openNeeds: () => void; openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; activeWorkspaceId: number | null; @@ -138,7 +145,10 @@ export async function handleNotifyOpen( continue; } if (intent.type === "direction") { - await deps.goToDirectionRef(intent.threadId, intent.direction); + await deps.goToDirectionRef(intent.threadId, intent.direction, { + repoId: intent.repoId, + sessionId: intent.sessionId, + }); continue; } if (intent.type === "needs") { @@ -158,8 +168,11 @@ async function sendOsNotification( 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, }); } @@ -197,10 +210,12 @@ export function useSystemNotifications() { const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); - const threadsById = useRef>({}); + const threadsById = useRef>({}); useEffect(() => { - const m: Record = {}; - for (const th of threads) m[th.id] = { title: th.title }; + const m: Record = {}; + for (const th of threads) { + m[th.id] = { title: th.title, workspaceId: th.workspace_id }; + } threadsById.current = m; }, [threads]); @@ -234,21 +249,27 @@ export function useSystemNotifications() { }; }, []); - // Click / action deep-link from the native bridge. + // Click / action deep-link from the native bridge. Also drain a click that + // arrived before React mounted (cold-start from a persisted notification). useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; + const deps = { + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + activeWorkspaceId, + }; void (async () => { try { unlisten = await listen("notify://open", (event) => { - void handleNotifyOpen(event.payload, { - selectWorkspace, - goToDirectionRef, - openNeeds, - openSettings, - activeWorkspaceId, - }); + void handleNotifyOpen(event.payload, deps); }); + const pending = await api.osNotifyTakePendingOpen(); + if (!cancelled && pending) { + void handleNotifyOpen(pending, deps); + } } catch { /* pure-vite */ } diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index eb0503fe..d039d50b 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -142,6 +142,7 @@ export interface NotifySessionRef { info: { session_id: number }; status: SessionStatus; directionId: number; + repoId: number; threadId: number; } @@ -150,8 +151,13 @@ 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; } /** One snapshot entry: human sample line + optional deep-link route. */ @@ -185,13 +191,16 @@ export function snapshotOf( sessions: Record, leadTurn: Record, processQuota: ProcessQuotaStatus | null, - threadsById: Record = {}, + threadsById: Record = {}, workspaceId: number | null = null, ): NotifySnapshot { const n = new Map(); for (const it of needs) { - // Notices are display-only (or self-clearing); only real questions ping Needs-you. - if (it.kind !== "question") continue; + // 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: { @@ -199,12 +208,14 @@ export function snapshotOf( threadId: it.thread_id, directionId: it.direction_id, askId: it.ask_id, - workspaceId: workspaceId ?? undefined, + workspaceId: ws, + openNeeds, }, }); } for (const a of asks) { const directionId = Number(a.dir); + const ws = threadsById[a.thread]?.workspaceId ?? workspaceId ?? undefined; n.set(`ask:${a.id}`, { sample: `${a.thread_title} · ${a.dir_name}`, route: { @@ -212,17 +223,20 @@ export function snapshotOf( threadId: a.thread, directionId: Number.isFinite(directionId) ? directionId : undefined, askId: a.id, - workspaceId: workspaceId ?? undefined, + workspaceId: 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: workspaceId ?? undefined, + workspaceId: ws, + openNeeds: true, }, }); } @@ -232,13 +246,15 @@ export function snapshotOf( o.statuses.forEach((s, i) => { if (s !== "review") return; const directionId = o.direction_ids[i]; + const ws = + threadsById[o.thread_id]?.workspaceId ?? workspaceId ?? undefined; r.set(`rev:${directionId}`, { sample: o.title, route: { kind: "review", threadId: o.thread_id, directionId, - workspaceId: workspaceId ?? undefined, + workspaceId: ws, }, }); }); @@ -247,27 +263,31 @@ export function snapshotOf( const stalled = new Map(); for (const s of Object.values(sessions)) { if (s.status !== "stalled") continue; - const title = threadsById[s.threadId]?.title ?? `thread ${s.threadId}`; + const meta = threadsById[s.threadId]; + const title = meta?.title ?? `thread ${s.threadId}`; stalled.set(`stall:worker:${s.info.session_id}`, { sample: `${title} · dir ${s.directionId}`, route: { kind: "stalled", threadId: s.threadId, directionId: s.directionId, - workspaceId: workspaceId ?? undefined, + repoId: s.repoId, + sessionId: s.info.session_id, + workspaceId: meta?.workspaceId ?? workspaceId ?? undefined, }, }); } for (const [tidStr, turn] of Object.entries(leadTurn)) { if (turn.state !== "stalled") continue; const tid = Number(tidStr); - const title = threadsById[tid]?.title ?? `thread ${tid}`; + const meta = threadsById[tid]; + const title = meta?.title ?? `thread ${tid}`; stalled.set(`stall:lead:${tid}`, { sample: title, route: { kind: "stalled", threadId: tid, - workspaceId: workspaceId ?? undefined, + workspaceId: meta?.workspaceId ?? workspaceId ?? undefined, }, }); } @@ -352,7 +372,12 @@ export function badgeCountFrom( asks: PermissionAsk[], writeTriggers: WriteTrigger[], ): number { - return needs.filter((n) => n.kind === "question").length + asks.length + writeTriggers.length; + return ( + needs.filter((n) => n.kind === "question" || n.kind === "notice_action_required") + .length + + asks.length + + writeTriggers.length + ); } /** @@ -376,7 +401,13 @@ export function isAppInForeground( /** Pure navigation intent for a notification click. UI layer applies it. */ export type NotifyOpenIntent = | { type: "workspace"; workspaceId: number } - | { type: "direction"; threadId: number; direction: string } + | { + type: "direction"; + threadId: number; + direction: string; + repoId?: number; + sessionId?: number; + } | { type: "needs" } | { type: "resources" }; @@ -384,7 +415,10 @@ export function planNotifyOpen(payload: { kind: string; threadId?: number | null; directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; workspaceId?: number | null; + openNeeds?: boolean | null; }): NotifyOpenIntent[] { const out: NotifyOpenIntent[] = []; if (payload.workspaceId != null) { @@ -394,10 +428,30 @@ export function planNotifyOpen(payload: { out.push({ type: "resources" }); 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"; - out.push({ type: "direction", threadId: payload.threadId, direction }); + 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") { diff --git a/src/state/store.tsx b/src/state/store.tsx index bbe6e611..54e86662 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -361,7 +361,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", @@ -2852,13 +2856,27 @@ 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) - : undefined; + const all = Object.values(sessions); + let live = + opts?.sessionId != null + ? all.find((s) => s.info.session_id === opts.sessionId) + : undefined; + if (!live && opts?.repoId != null && Number.isFinite(directionId)) { + live = all.find( + (s) => s.directionId === directionId && s.repoId === opts.repoId, + ); + } + if (!live && Number.isFinite(directionId)) { + live = all.find((s) => s.directionId === directionId); + } if (live) { setActiveThreadId(thread); openWorker(live.directionId, live.repoId); @@ -2898,7 +2916,26 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, [activeThreadId]); // Needs-you: poll workspace-wide, plus a push refresh when the coordinator - // signals a new ask (}, [activeWorkspaceId, refreshNeeds]); + // signals a new ask (needs-you://changed). Poll is the safety net; the event + // makes new questions appear near-instantly. + useEffect(() => { + if (activeWorkspaceId == null) { + setNeeds([]); + return; + } + let alive = true; + const tick = () => { + if (alive) void refreshNeeds(); + }; + tick(); + const h = setInterval(tick, 4000); + const unChanged = listen("needs-you://changed", tick); + return () => { + alive = false; + clearInterval(h); + void unChanged.then((f) => f()); + }; + }, [activeWorkspaceId, refreshNeeds]); // Board overview poll: review transitions (and any status drift) stay fresh // even when the kanban is unmounted. 10s matches the previous notify hook. diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts index 6b8c57bb..9b53e030 100644 --- a/tests/frontend/notifications.test.ts +++ b/tests/frontend/notifications.test.ts @@ -111,8 +111,8 @@ test("snapshotOf keys cover needs / asks / writeTriggers / review", () => { assert.equal(snap.review.has("rev:11"), false); }); -test("snapshotOf skips non-answerable needs (stall notices stay in stalled lane)", () => { - const snap = snapshotOf( +test("snapshotOf skips self-clearing notices but keeps action-required notices", () => { + const silent = snapshotOf( [need({ ask_id: 1, kind: "notice" })], [], [], @@ -121,7 +121,17 @@ test("snapshotOf skips non-answerable needs (stall notices stay in stalled lane) {}, null, ); - assert.equal(snap.needs.size, 0); + 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", () => { @@ -135,6 +145,7 @@ test("snapshotOf captures stalled workers and leads", () => { info: { session_id: 99 }, status: "stalled", directionId: 5, + repoId: 8, threadId: 2, }, }, @@ -144,6 +155,8 @@ test("snapshotOf captures stalled workers and leads", () => { ); assert.equal(snap.stalled.get("stall:worker:99")?.sample, "Issue B · dir 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:lead:2")?.sample, "Issue B"); assert.equal(snap.stalled.has("stall:lead:3"), false); }); @@ -255,11 +268,15 @@ test("foreground gate prefers explicit window focus", () => { 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: 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 + 2); + assert.equal(n, 1 + 1 + 1 + 2); }); test("notifyCopyKeys covers every category", () => { @@ -298,3 +315,39 @@ test("planNotifyOpen routes quota to resources and lead-only stalled to 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, + }, + ], + ); +}); From 63712766547a8e009553453d5137ef3c504c7c44 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 28 Jul 2026 22:43:20 +0800 Subject: [PATCH 03/35] fix(notify): prevent deep-link races and re-query permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 workspace 切换竞态、pending click 重放、权限缓存遮挡系统恢复,以及 Dock badge 跨 workspace 计数;stalled 文案去掉硬编码英文词。 --- src-tauri/src/lib.rs | 1 + src-tauri/src/os_notify.rs | 34 +++++--- src/lib/api.ts | 1 + src/lib/notifications.ts | 117 +++++++++++++++++++++++---- src/lib/notificationsCore.ts | 6 +- tests/frontend/notifications.test.ts | 2 +- 6 files changed, 128 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5fde1a0d..27426d0f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -319,6 +319,7 @@ pub fn run() { os_notify::os_notify_request_permission, os_notify::os_notify_send, os_notify::os_notify_take_pending_open, + os_notify::os_notify_ack_open, commands::session_for, commands::session_meta, commands::effective_config, diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 5ef07941..0468f2e9 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -166,12 +166,12 @@ fn handle_response(app: AppHandle, response: NotificationResponse } focus_main_window(&app); let payload = payload_from_user_info(&response.user_info); - // Always retain the latest pending open so a cold-start click is not lost - // if the frontend listener is not mounted yet. + // Retain as pending first so a cold-start click survives if no frontend + // listener is mounted yet. Live handlers must ack/clear after handling. if let Ok(mut guard) = pending_open().lock() { *guard = Some(payload.clone()); } - if let Err(err) = app.emit(OPEN_EVENT, payload) { + if let Err(err) = app.emit(OPEN_EVENT, payload.clone()) { eprintln!("[weft] os_notify emit {OPEN_EVENT}: {err}"); } } @@ -202,21 +202,23 @@ pub fn init(app: &AppHandle) -> Result<(), String> { /// `"granted" | "denied" | "prompt"` — mirrors the frontend `NotifyPermission`. #[tauri::command] pub async fn os_notify_permission() -> Result { - if let Ok(guard) = perm_settled().lock() { - if let Some(settled) = guard.as_ref() { - return Ok(settled.clone()); - } - } let mgr = manager()?.clone(); - // Mock manager (unsigned macOS dev) always reports granted; real macOS - // distinguishes NotDetermined as false without a dedicated "prompt" API, so - // we map false → "prompt" until a request settles to denied/granted. + // 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) => Ok("prompt".to_string()), + Ok(false) => { + if let Ok(guard) = perm_settled().lock() { + if guard.as_deref() == Some("denied") { + return Ok("denied".to_string()); + } + } + Ok("prompt".to_string()) + } Err(e) => Err(format!("os_notify permission: {e}")), } } @@ -263,6 +265,14 @@ pub fn os_notify_take_pending_open() -> Option { pending_open().lock().ok().and_then(|mut g| g.take()) } +/// Clear a retained pending open after the frontend has handled a live event. +#[tauri::command] +pub fn os_notify_ack_open() { + if let Ok(mut guard) = pending_open().lock() { + *guard = None; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib/api.ts b/src/lib/api.ts index 691f7015..7125c888 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -364,6 +364,7 @@ export const api = { // Desktop OS notifications via user-notify (click deep-link capable). osNotifyPermission: () => invoke("os_notify_permission"), osNotifyRequestPermission: () => invoke("os_notify_request_permission"), + osNotifyAckOpen: () => invoke("os_notify_ack_open"), osNotifyTakePendingOpen: () => invoke<{ kind: string; diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index f34e2e13..3f4b651b 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next"; import { useStore } from "../state/store"; import { api } from "./api"; import { - badgeCountFrom, diffForNotifications, isAppInForeground, isInQuietHours, @@ -117,10 +116,30 @@ export interface OsNotifyOpenEvent { } /** Apply a notification click to the in-app navigation surface. */ -export async function handleNotifyOpen( +const PENDING_NAV_KEY = "weft-notify-pending-nav"; + +function stashPendingNav(payload: OsNotifyOpenEvent): void { + try { + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(payload)); + } catch { + /* ignore quota / private mode */ + } +} + +export function takePendingNav(): OsNotifyOpenEvent | null { + try { + const raw = sessionStorage.getItem(PENDING_NAV_KEY); + if (!raw) return null; + sessionStorage.removeItem(PENDING_NAV_KEY); + return JSON.parse(raw) as OsNotifyOpenEvent; + } catch { + return null; + } +} + +export async function applyNotifyIntents( payload: OsNotifyOpenEvent, deps: { - selectWorkspace: (id: number) => Promise | void; goToDirectionRef: ( thread: number, dir: string, @@ -128,18 +147,10 @@ export async function handleNotifyOpen( ) => Promise; openNeeds: () => void; openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; - activeWorkspaceId: number | null; }, ): Promise { - await focusMainWindow(); - const intents = planNotifyOpen(payload); + const intents = planNotifyOpen(payload).filter((i) => i.type !== "workspace"); for (const intent of intents) { - if (intent.type === "workspace") { - if (intent.workspaceId !== deps.activeWorkspaceId) { - await deps.selectWorkspace(intent.workspaceId); - } - continue; - } if (intent.type === "resources") { deps.openSettings("resources"); continue; @@ -157,6 +168,39 @@ export async function handleNotifyOpen( } } +/** 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; + activeWorkspaceId: number | null; + }, +): Promise { + await focusMainWindow(); + const intents = planNotifyOpen(payload); + const workspaceIntent = intents.find((i) => i.type === "workspace"); + if ( + workspaceIntent && + workspaceIntent.type === "workspace" && + workspaceIntent.workspaceId !== deps.activeWorkspaceId + ) { + // Defer the rest until the store finishes the workspace switch effect. + // A second selectWorkspace(activeWorkspaceId) otherwise races and clears + // the deep-link destination. + stashPendingNav(payload); + await deps.selectWorkspace(workspaceIntent.workspaceId); + return; + } + await applyNotifyIntents(payload, deps); +} + async function sendOsNotification( title: string, body: string, @@ -198,6 +242,7 @@ export function useSystemNotifications() { notifyCategories, quietHours, activeWorkspaceId, + needsByWorkspace, selectWorkspace, goToDirectionRef, openNeeds, @@ -264,11 +309,20 @@ export function useSystemNotifications() { void (async () => { try { unlisten = await listen("notify://open", (event) => { - void handleNotifyOpen(event.payload, deps); + void (async () => { + await handleNotifyOpen(event.payload, deps); + // Live delivery was handled — drop any retained pending copy so a + // later effect re-run does not replay the same click. + try { + await api.osNotifyAckOpen(); + } catch { + /* pure-vite */ + } + })(); }); const pending = await api.osNotifyTakePendingOpen(); if (!cancelled && pending) { - void handleNotifyOpen(pending, deps); + await handleNotifyOpen(pending, deps); } } catch { /* pure-vite */ @@ -287,13 +341,42 @@ export function useSystemNotifications() { activeWorkspaceId, ]); - // Dock / taskbar badge tracks actionable Needs-you count. + // Finish a deep link that had to switch workspaces first. + useEffect(() => { + const pending = takePendingNav(); + if (!pending) return; + if ( + pending.workspaceId != null && + pending.workspaceId !== activeWorkspaceId + ) { + // Not settled yet — put it back. + try { + sessionStorage.setItem( + "weft-notify-pending-nav", + JSON.stringify(pending), + ); + } catch { + /* ignore */ + } + return; + } + void applyNotifyIntents(pending, { + goToDirectionRef, + openNeeds, + openSettings, + }); + }, [activeWorkspaceId, goToDirectionRef, openNeeds, openSettings]); + + // Dock / taskbar badge tracks actionable Needs-you across all workspaces. useEffect(() => { - const count = badgeCountFrom(needs, asks, writeTriggers); + const fromWs = Object.values(needsByWorkspace).reduce((a, b) => a + b, 0); + // Permission asks are global; they are not in needsByWorkspace. Add them + // once on top of the per-workspace totals. + const count = fromWs + asks.length; if (lastBadge.current === count) return; lastBadge.current = count; void setDockBadge(count); - }, [needs, asks, writeTriggers]); + }, [needsByWorkspace, asks]); useEffect(() => { const next = snapshotOf( diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index d039d50b..f3f44bd5 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -264,9 +264,9 @@ export function snapshotOf( for (const s of Object.values(sessions)) { if (s.status !== "stalled") continue; const meta = threadsById[s.threadId]; - const title = meta?.title ?? `thread ${s.threadId}`; + const title = meta?.title ?? `#${s.threadId}`; stalled.set(`stall:worker:${s.info.session_id}`, { - sample: `${title} · dir ${s.directionId}`, + sample: `${title} · #${s.directionId}`, route: { kind: "stalled", threadId: s.threadId, @@ -281,7 +281,7 @@ export function snapshotOf( if (turn.state !== "stalled") continue; const tid = Number(tidStr); const meta = threadsById[tid]; - const title = meta?.title ?? `thread ${tid}`; + const title = meta?.title ?? `#${tid}`; stalled.set(`stall:lead:${tid}`, { sample: title, route: { diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts index 9b53e030..847dbd6b 100644 --- a/tests/frontend/notifications.test.ts +++ b/tests/frontend/notifications.test.ts @@ -153,7 +153,7 @@ test("snapshotOf captures stalled workers and leads", () => { null, { 2: { title: "Issue B" } }, ); - assert.equal(snap.stalled.get("stall:worker:99")?.sample, "Issue B · dir 5"); + 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); From 270ae677e9c8581e6642b561c3a48da1484350e2 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 28 Jul 2026 22:55:30 +0800 Subject: [PATCH 04/35] fix(notify): honor denied permission and stabilize click delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 pending 命令返回 Result;前端信任 denied 状态、避免 badge 重复计数,拆分 live/pending 监听,并用跨 workspace 线程索引修正全局通知归属。 --- src-tauri/src/os_notify.rs | 17 +++++--- src/lib/notifications.ts | 86 +++++++++++++++++++++++++------------- src/state/store.tsx | 28 ++++++++++--- 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 0468f2e9..9cad7ba5 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -261,16 +261,21 @@ pub async fn os_notify_send(req: NotifySendRequest) -> Result<(), String> { /// Drain a click that arrived before the frontend listener was ready. #[tauri::command] -pub fn os_notify_take_pending_open() -> Option { - pending_open().lock().ok().and_then(|mut g| g.take()) +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() { - if let Ok(mut guard) = pending_open().lock() { - *guard = None; - } +pub fn os_notify_ack_open() -> Result<(), String> { + let mut guard = pending_open() + .lock() + .map_err(|e| format!("os_notify pending lock poisoned: {e}"))?; + *guard = None; + Ok(()) } #[cfg(test)] diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 3f4b651b..06511833 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -31,10 +31,9 @@ function asNotifyPermission(raw: string): NotifyPermission { export async function notifyPermission(): Promise { try { const p = asNotifyPermission(await api.osNotifyPermission()); - if (p === "granted") return "granted"; - // user-notify collapses Denied + NotDetermined to false. Prefer the Web - // Notification permission bit when present so Settings can show the - // "open System Settings" recovery instead of re-prompting forever. + // 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; if (web === "denied") return "denied"; @@ -243,6 +242,7 @@ export function useSystemNotifications() { quietHours, activeWorkspaceId, needsByWorkspace, + threadWorkspaceById, selectWorkspace, goToDirectionRef, openNeeds, @@ -257,12 +257,19 @@ export function useSystemNotifications() { const threadsById = useRef>({}); useEffect(() => { - const m: Record = {}; + const m: Record = { + ...threadsById.current, + }; + for (const [idStr, ws] of Object.entries(threadWorkspaceById)) { + const id = Number(idStr); + const prev = m[id]; + m[id] = { title: prev?.title ?? `#${id}`, workspaceId: ws }; + } for (const th of threads) { m[th.id] = { title: th.title, workspaceId: th.workspace_id }; } threadsById.current = m; - }, [threads]); + }, [threads, threadWorkspaceById]); // OS permission, settled once per enable. useEffect(() => { @@ -294,25 +301,40 @@ export function useSystemNotifications() { }; }, []); - // Click / action deep-link from the native bridge. Also drain a click that - // arrived before React mounted (cold-start from a persisted notification). + // 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, + activeWorkspaceId, + }); useEffect(() => { - let unlisten: (() => void) | undefined; - let cancelled = false; - const deps = { + navDepsRef.current = { selectWorkspace, goToDirectionRef, openNeeds, openSettings, activeWorkspaceId, }; + }, [ + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + activeWorkspaceId, + ]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; void (async () => { try { unlisten = await listen("notify://open", (event) => { void (async () => { - await handleNotifyOpen(event.payload, deps); - // Live delivery was handled — drop any retained pending copy so a - // later effect re-run does not replay the same click. + await handleNotifyOpen(event.payload, navDepsRef.current); + // Live delivery was handled — drop any retained pending copy. try { await api.osNotifyAckOpen(); } catch { @@ -320,26 +342,34 @@ export function useSystemNotifications() { } })(); }); + } catch { + /* pure-vite */ + } + if (cancelled) unlisten?.(); + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + // Cold-start only: drain a pending open once after mount. + useEffect(() => { + let cancelled = false; + void (async () => { + try { const pending = await api.osNotifyTakePendingOpen(); if (!cancelled && pending) { - await handleNotifyOpen(pending, deps); + await handleNotifyOpen(pending, navDepsRef.current); } } catch { /* pure-vite */ } - if (cancelled) unlisten?.(); })(); return () => { cancelled = true; - unlisten?.(); }; - }, [ - selectWorkspace, - goToDirectionRef, - openNeeds, - openSettings, - activeWorkspaceId, - ]); + }, []); // Finish a deep link that had to switch workspaces first. useEffect(() => { @@ -368,15 +398,13 @@ export function useSystemNotifications() { }, [activeWorkspaceId, goToDirectionRef, openNeeds, openSettings]); // Dock / taskbar badge tracks actionable Needs-you across all workspaces. + // needsByWorkspace already includes questions + asks + writeTriggers per ws. useEffect(() => { - const fromWs = Object.values(needsByWorkspace).reduce((a, b) => a + b, 0); - // Permission asks are global; they are not in needsByWorkspace. Add them - // once on top of the per-workspace totals. - const count = fromWs + asks.length; + const count = Object.values(needsByWorkspace).reduce((a, b) => a + b, 0); if (lastBadge.current === count) return; lastBadge.current = count; void setDockBadge(count); - }, [needsByWorkspace, asks]); + }, [needsByWorkspace]); useEffect(() => { const next = snapshotOf( diff --git a/src/state/store.tsx b/src/state/store.tsx index 54e86662..c7074b08 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -207,6 +207,8 @@ interface Store { activeWorkspaceId: number | null; repos: RepoRef[]; threads: Thread[]; + /** Cumulative thread_id → workspace_id map across visited workspaces. */ + threadWorkspaceById: Record; directionsByThread: Record; worktreesByDirection: Record; @@ -594,6 +596,21 @@ export function StoreProvider({ children }: { children: ReactNode }) { activeWorkspaceIdRef.current = activeWorkspaceId; const [repos, setRepos] = useState([]); const [threads, setThreads] = useState([]); + const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); + const rememberThreads = useCallback((list: Thread[]) => { + rememberThreads(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; + }); + }, []); const [directionsByThread, setDirections] = useState>({}); const [worktreesByDirection, setWorktrees] = useState>({}); const [activeThreadId, setActiveThreadId] = useState(null); @@ -945,7 +962,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setSelectedRepoId(null); const [r, t] = await Promise.all([api.listRepos(id), api.listThreads(id)]); setRepos(r); - setThreads(t); + rememberThreads(t); setDirections({}); setWorktrees({}); setActiveThreadId(null); @@ -954,7 +971,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setHomeTab("board"); setProposal(null); setOverview([]); - }, []); + }, [rememberThreads]); const loadThreadChildren = useCallback(async (threadId: number) => { const dirs = await api.listDirections(threadId); @@ -1262,7 +1279,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { api.listThreads(activeWorkspaceId), refreshOverview(), ]); - setThreads(threadList); + rememberThreads(threadList); return t; }, [activeWorkspaceId, refreshOverview], @@ -1272,7 +1289,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]; @@ -2375,7 +2392,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; @@ -3095,6 +3112,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { activeWorkspaceId, repos, threads, + threadWorkspaceById, directionsByThread, worktreesByDirection, activeThreadId, From 305c6dc684c97bd33d6d56a6501e36f7896fa635 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 28 Jul 2026 23:06:43 +0800 Subject: [PATCH 05/35] fix(notify): stop rememberThreads recursive self-call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectWorkspace 加载线程时误递归调用 helper 导致栈溢出,改为 setThreads + 更新 workspace 索引。 --- src/state/store.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/store.tsx b/src/state/store.tsx index c7074b08..4febce83 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -598,7 +598,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [threads, setThreads] = useState([]); const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); const rememberThreads = useCallback((list: Thread[]) => { - rememberThreads(list); + setThreads(list); setThreadWorkspaceById((prev) => { let changed = false; const next = { ...prev }; From 43a9a5be756f1b0b83e44e2bf550c5bebe124aba Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 06:52:51 +0800 Subject: [PATCH 06/35] fix(notify): surface main-window focus failures on click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击通知时 unminimize/show/focus 失败不再静默吞掉,改为显式日志,避免看起来像点击无响应。 --- src-tauri/src/os_notify.rs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 9cad7ba5..c11bd63e 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -139,12 +139,17 @@ fn user_info_from_req(req: &NotifySendRequest) -> HashMap { info } -fn focus_main_window(app: &AppHandle) { - if let Some(win) = app.get_webview_window("main") { - let _ = win.unminimize(); - let _ = win.show(); - let _ = win.set_focus(); - } +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) { @@ -164,12 +169,21 @@ fn handle_response(app: AppHandle, response: NotificationResponse if !open { return; } - focus_main_window(&app); + 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. - if let Ok(mut guard) = pending_open().lock() { - *guard = Some(payload.clone()); + 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}"); From 18d5506b37fedd5c6fb5f4ca34d6f1a9eda9ee67 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 07:01:18 +0800 Subject: [PATCH 07/35] fix(notify): wait for workspace load before deep-link apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨 workspace 点击通知时,等 selectWorkspace 完成加载后再消费 pending 路由,避免异步 reset 清掉目标视图。 --- src/lib/notifications.ts | 33 +++++++++++++++++++++++++++++++-- src/state/store.tsx | 4 ++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 06511833..bc3ebee3 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -243,6 +243,7 @@ export function useSystemNotifications() { activeWorkspaceId, needsByWorkspace, threadWorkspaceById, + workspaceLoadSeq, selectWorkspace, goToDirectionRef, openNeeds, @@ -371,7 +372,10 @@ export function useSystemNotifications() { }; }, []); - // Finish a deep link that had to switch workspaces first. + // 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 pendingAppliedSeq = useRef(null); useEffect(() => { const pending = takePendingNav(); if (!pending) return; @@ -390,12 +394,37 @@ export function useSystemNotifications() { } return; } + if (pendingAppliedSeq.current === workspaceLoadSeq) { + // Already applied for this load; put back only if still relevant. + return; + } + // If a workspace switch just started, wait for its load seq bump. + // workspaceLoadSeq is 0 before any selection completes; still allow apply + // once activeWorkspaceId already matches the target after a completed load. + if (pending.workspaceId != null && workspaceLoadSeq === 0) { + try { + sessionStorage.setItem( + "weft-notify-pending-nav", + JSON.stringify(pending), + ); + } catch { + /* ignore */ + } + return; + } + pendingAppliedSeq.current = workspaceLoadSeq; void applyNotifyIntents(pending, { goToDirectionRef, openNeeds, openSettings, }); - }, [activeWorkspaceId, goToDirectionRef, openNeeds, openSettings]); + }, [ + activeWorkspaceId, + workspaceLoadSeq, + goToDirectionRef, + openNeeds, + openSettings, + ]); // Dock / taskbar badge tracks actionable Needs-you across all workspaces. // needsByWorkspace already includes questions + asks + writeTriggers per ws. diff --git a/src/state/store.tsx b/src/state/store.tsx index 4febce83..f786f539 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -209,6 +209,8 @@ interface Store { threads: Thread[]; /** Cumulative thread_id → workspace_id map across visited workspaces. */ threadWorkspaceById: Record; + /** Bumps after each selectWorkspace finishes loading repos/threads. */ + workspaceLoadSeq: number; directionsByThread: Record; worktreesByDirection: Record; @@ -597,6 +599,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [repos, setRepos] = useState([]); const [threads, setThreads] = useState([]); const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); + const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -971,6 +974,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setHomeTab("board"); setProposal(null); setOverview([]); + setWorkspaceLoadSeq((n) => n + 1); }, [rememberThreads]); const loadThreadChildren = useCallback(async (threadId: number) => { From d9b11e1d8a88bb1815b8a4102013fcf0bdcab478 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 07:01:36 +0800 Subject: [PATCH 08/35] fix(notify): expose workspaceLoadSeq on store value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补挂 workspaceLoadSeq 到 context value,供通知深链等待 workspace 加载完成。 --- src/state/store.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/state/store.tsx b/src/state/store.tsx index f786f539..60ddf222 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -3117,6 +3117,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { repos, threads, threadWorkspaceById, + workspaceLoadSeq, directionsByThread, worktreesByDirection, activeThreadId, From ed03adcabcbb5d4409d3dab379568b5047e93756 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 18:37:54 +0800 Subject: [PATCH 09/35] fix(notify): badge action-required, global quota, settings deep-link - workspace_needs_counts includes notice_action_required for dock/switcher badges - quota routes omit workspace switches so Resources stays process-global - openSettings no longer resnapshots when Settings is already open; live page via settingsRequestedPage --- src-tauri/src/commands.rs | 12 +++++++++--- src/lib/notifications.ts | 3 ++- src/lib/notificationsCore.ts | 10 ++++++---- src/nav/SettingsDialog.tsx | 12 +++++++++++- src/state/store.tsx | 16 ++++++++++++++++ tests/frontend/notifications.test.ts | 8 ++++++-- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f8e28305..84f225c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2378,10 +2378,16 @@ pub async fn workspace_needs_counts( 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)? diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index bc3ebee3..62cd2c88 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -427,7 +427,8 @@ export function useSystemNotifications() { ]); // Dock / taskbar badge tracks actionable Needs-you across all workspaces. - // needsByWorkspace already includes questions + asks + writeTriggers per ws. + // 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; diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index f3f44bd5..97f6ef34 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -305,7 +305,7 @@ export function snapshotOf( sample, route: { kind: "quota", - workspaceId: workspaceId ?? undefined, + // Quota / Resources is global — do not pin a workspace that may be stale by click time. }, }); } @@ -421,13 +421,15 @@ export function planNotifyOpen(payload: { openNeeds?: boolean | null; }): NotifyOpenIntent[] { const out: NotifyOpenIntent[] = []; - if (payload.workspaceId != null) { - out.push({ type: "workspace", workspaceId: payload.workspaceId }); - } + // 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 }); + } // Write triggers / action-required notices: open the Needs-you surface so the // human can act on the control the notification advertised. if (payload.openNeeds) { diff --git a/src/nav/SettingsDialog.tsx b/src/nav/SettingsDialog.tsx index 8e5ef121..22a6519e 100644 --- a/src/nav/SettingsDialog.tsx +++ b/src/nav/SettingsDialog.tsx @@ -83,17 +83,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); + }, [settingsRequestedPage]); const [query, setQuery] = useState(""); const groups = useMemo(() => { diff --git a/src/state/store.tsx b/src/state/store.tsx index 60ddf222..df1ad539 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -401,6 +401,10 @@ 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). */ + settingsRequestedPage: SettingsPage | null; /** Jump to the workspace home's Repos tab. */ openRepoMap: () => void; refreshRepoMap: () => Promise; @@ -653,6 +657,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [repoAnalysisActive, setRepoAnalysisActive] = useState(false); const [homeTab, setHomeTab] = useState("board"); const [settingsInitialPage, setSettingsInitialPage] = useState(null); + const [settingsRequestedPage, setSettingsRequestedPage] = useState(null); const [curatorThreadId, setCuratorThreadId] = useState(null); const [repoDrawerOpen, setRepoDrawerOpen] = useState(false); const [repoDrawerTab, setRepoDrawerTabState] = useState<"detail" | "curator">("detail"); @@ -1038,6 +1043,14 @@ export function StoreProvider({ children }: { children: ReactNode }) { const openSettings = useCallback( (page?: SettingsPage) => { + // 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) { + setSettingsRequestedPage(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 = { @@ -1050,6 +1063,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setViewing(null); setShowNeeds(false); setSettingsInitialPage(page ?? null); + setSettingsRequestedPage(page ?? null); setHomeTab("settings"); }, [homeTab, activeThreadId, viewing, showNeeds], @@ -1062,6 +1076,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"); @@ -3203,6 +3218,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { closeSettings, settingsInitialPage, clearSettingsInitialPage, + settingsRequestedPage, openRepoMap, refreshRepoMap, refreshReposAndMap, diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts index 847dbd6b..cc0276cf 100644 --- a/tests/frontend/notifications.test.ts +++ b/tests/frontend/notifications.test.ts @@ -162,9 +162,10 @@ test("snapshotOf captures stalled workers and leads", () => { }); test("snapshotOf only surfaces degraded quota keyed by transitionSeq", () => { - const snap = snapshotOf([], [], [], [], {}, {}, quota("degraded", 4)); + 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); }); @@ -303,8 +304,11 @@ test("planNotifyOpen routes needs/review to direction and switches workspace", ( }); 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: "workspace", workspaceId: 1 }, { type: "resources" }, ]); assert.deepEqual(planNotifyOpen({ kind: "stalled", threadId: 4 }), [ From 1ce13bc1b4a406d6f04e527b51ef4ee0d8213807 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 18:50:16 +0800 Subject: [PATCH 10/35] fix(notify): settle workspace loads and harden deep-link edge cases - wait for both selectWorkspace load seq bumps before applying cross-workspace routes - settings deep links use a seq token so repeated Resources clicks re-apply - stalled routes require owning workspace; curator stalls open Repo Map curator - restore pending OS click when StrictMode cancels the first cold-start drain --- src-tauri/src/lib.rs | 1 + src-tauri/src/os_notify.rs | 20 +++++ src/lib/api.ts | 13 +++ src/lib/notifications.ts | 128 ++++++++++++++++++++------- src/lib/notificationsCore.ts | 44 ++++++++- src/nav/SettingsDialog.tsx | 2 +- src/state/store.tsx | 25 ++++-- tests/frontend/notifications.test.ts | 57 +++++++++++- 8 files changed, 247 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27426d0f..54184d42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -320,6 +320,7 @@ pub fn run() { os_notify::os_notify_send, os_notify::os_notify_take_pending_open, os_notify::os_notify_ack_open, + os_notify::os_notify_restore_pending_open, commands::session_for, commands::session_meta, commands::effective_config, diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index c11bd63e..01a4d45b 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -136,6 +136,12 @@ fn user_info_from_req(req: &NotifySendRequest) -> HashMap { 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 } @@ -292,6 +298,17 @@ pub fn os_notify_ack_open() -> Result<(), String> { Ok(()) } +/// 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}"))?; + *guard = Some(payload); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -309,6 +326,7 @@ mod tests { 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); @@ -320,6 +338,7 @@ mod tests { 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] @@ -335,6 +354,7 @@ mod tests { ask_id: None, workspace_id: None, open_needs: None, + open_curator: None, }; assert!(user_info_from_req(&req).is_empty()); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 7125c888..e9d41252 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -375,7 +375,19 @@ export const api = { 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; @@ -387,6 +399,7 @@ export const api = { 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 → diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 62cd2c88..2b222625 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -112,30 +112,56 @@ export interface OsNotifyOpenEvent { askId?: number | null; workspaceId?: number | null; openNeeds?: boolean | null; + openCurator?: boolean | null; } /** Apply a notification click to the in-app navigation surface. */ const PENDING_NAV_KEY = "weft-notify-pending-nav"; -function stashPendingNav(payload: OsNotifyOpenEvent): void { +type PendingNav = OsNotifyOpenEvent & { + /** + * Absolute workspaceLoadSeq that must be reached before applying intents. + * Negative values encode "currentSeq + abs(value)" and are resolved on first + * settle attempt so we wait for both the direct selectWorkspace load and the + * store's [activeWorkspaceId] re-load. + */ + expectedLoadSeq?: number; +}; + +function stashPendingNav( + payload: OsNotifyOpenEvent, + opts?: { expectedSeqDelta?: number }, +): void { try { - sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(payload)); + const pending: PendingNav = { ...payload }; + if (opts?.expectedSeqDelta != null) { + pending.expectedLoadSeq = -Math.abs(opts.expectedSeqDelta); + } + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); } catch { /* ignore quota / private mode */ } } -export function takePendingNav(): OsNotifyOpenEvent | null { +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 OsNotifyOpenEvent; + return JSON.parse(raw) as PendingNav; } catch { return null; } } +function putPendingNav(pending: PendingNav): void { + try { + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); + } catch { + /* ignore */ + } +} + export async function applyNotifyIntents( payload: OsNotifyOpenEvent, deps: { @@ -146,6 +172,7 @@ export async function applyNotifyIntents( ) => 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"); @@ -154,6 +181,10 @@ export async function applyNotifyIntents( 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, @@ -179,6 +210,7 @@ export async function handleNotifyOpen( ) => Promise; openNeeds: () => void; openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; + openCurator: () => void; activeWorkspaceId: number | null; }, ): Promise { @@ -192,8 +224,9 @@ export async function handleNotifyOpen( ) { // Defer the rest until the store finishes the workspace switch effect. // A second selectWorkspace(activeWorkspaceId) otherwise races and clears - // the deep-link destination. - stashPendingNav(payload); + // the deep-link destination. Wait for +2 load-seq bumps (direct call + + // the [activeWorkspaceId] effect) before applying intents. + stashPendingNav(payload, { expectedSeqDelta: 2 }); await deps.selectWorkspace(workspaceIntent.workspaceId); return; } @@ -216,6 +249,7 @@ async function sendOsNotification( askId: route.askId ?? null, workspaceId: route.workspaceId ?? null, openNeeds: route.openNeeds ?? null, + openCurator: route.openCurator ?? null, }); } @@ -248,6 +282,7 @@ export function useSystemNotifications() { goToDirectionRef, openNeeds, openSettings, + openCurator, } = useStore(); const { t } = useTranslation(); const prev = useRef(null); @@ -256,18 +291,31 @@ export function useSystemNotifications() { const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); - const threadsById = useRef>({}); + const threadsById = useRef< + Record + >({}); useEffect(() => { - const m: Record = { + 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 prev = m[id]; - m[id] = { title: prev?.title ?? `#${id}`, workspaceId: ws }; + const prevMeta = m[id]; + m[id] = { + title: prevMeta?.title ?? `#${id}`, + workspaceId: ws, + kind: prevMeta?.kind, + }; } for (const th of threads) { - m[th.id] = { title: th.title, workspaceId: th.workspace_id }; + m[th.id] = { + title: th.title, + workspaceId: th.workspace_id, + kind: th.kind, + }; } threadsById.current = m; }, [threads, threadWorkspaceById]); @@ -309,6 +357,7 @@ export function useSystemNotifications() { goToDirectionRef, openNeeds, openSettings, + openCurator, activeWorkspaceId, }); useEffect(() => { @@ -317,6 +366,7 @@ export function useSystemNotifications() { goToDirectionRef, openNeeds, openSettings, + openCurator, activeWorkspaceId, }; }, [ @@ -324,6 +374,7 @@ export function useSystemNotifications() { goToDirectionRef, openNeeds, openSettings, + openCurator, activeWorkspaceId, ]); @@ -354,15 +405,24 @@ export function useSystemNotifications() { }; }, []); - // Cold-start only: drain a pending open once after mount. + // Cold-start only: drain a pending open once after mount. If StrictMode + // cancels the first invocation after take(), put the payload back so the + // remount can still consume it. useEffect(() => { let cancelled = false; void (async () => { try { const pending = await api.osNotifyTakePendingOpen(); - if (!cancelled && pending) { - await handleNotifyOpen(pending, navDepsRef.current); + if (!pending) return; + if (cancelled) { + try { + await api.osNotifyRestorePendingOpen(pending); + } catch { + /* pure-vite / older backend */ + } + return; } + await handleNotifyOpen(pending, navDepsRef.current); } catch { /* pure-vite */ } @@ -376,6 +436,7 @@ export function useSystemNotifications() { // selectWorkspace has finished loading (workspaceLoadSeq) so a concurrent // selection reset cannot clear the destination right after we navigate. const pendingAppliedSeq = useRef(null); + const pendingBaselineSeq = useRef(null); useEffect(() => { const pending = takePendingNav(); if (!pending) return; @@ -384,39 +445,41 @@ export function useSystemNotifications() { pending.workspaceId !== activeWorkspaceId ) { // Not settled yet — put it back. - try { - sessionStorage.setItem( - "weft-notify-pending-nav", - JSON.stringify(pending), - ); - } catch { - /* ignore */ + putPendingNav(pending); + return; + } + // Resolve relative expectedLoadSeq markers against the seq observed when + // the pending route first becomes eligible for the target workspace. + if (pending.expectedLoadSeq != null && pending.expectedLoadSeq < 0) { + if (pendingBaselineSeq.current == null) { + pendingBaselineSeq.current = workspaceLoadSeq; } + pending.expectedLoadSeq = + pendingBaselineSeq.current + Math.abs(pending.expectedLoadSeq); + } + if ( + pending.expectedLoadSeq != null && + workspaceLoadSeq < pending.expectedLoadSeq + ) { + putPendingNav(pending); return; } if (pendingAppliedSeq.current === workspaceLoadSeq) { - // Already applied for this load; put back only if still relevant. + // Already applied for this load. return; } // If a workspace switch just started, wait for its load seq bump. - // workspaceLoadSeq is 0 before any selection completes; still allow apply - // once activeWorkspaceId already matches the target after a completed load. if (pending.workspaceId != null && workspaceLoadSeq === 0) { - try { - sessionStorage.setItem( - "weft-notify-pending-nav", - JSON.stringify(pending), - ); - } catch { - /* ignore */ - } + putPendingNav(pending); return; } pendingAppliedSeq.current = workspaceLoadSeq; + pendingBaselineSeq.current = null; void applyNotifyIntents(pending, { goToDirectionRef, openNeeds, openSettings, + openCurator, }); }, [ activeWorkspaceId, @@ -424,6 +487,7 @@ export function useSystemNotifications() { goToDirectionRef, openNeeds, openSettings, + openCurator, ]); // Dock / taskbar badge tracks actionable Needs-you across all workspaces. diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index 97f6ef34..19f802c9 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -144,6 +144,8 @@ export interface NotifySessionRef { directionId: number; repoId: number; threadId: number; + /** Owning workspace when known (adopted live sessions may predate a visit). */ + workspaceId?: number; } /** Deep-link fields carried through the OS notification `user_info`. */ @@ -158,6 +160,8 @@ export interface NotifyRoute { 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. */ @@ -191,7 +195,10 @@ export function snapshotOf( sessions: Record, leadTurn: Record, processQuota: ProcessQuotaStatus | null, - threadsById: Record = {}, + threadsById: Record< + number, + { title: string; workspaceId?: number; kind?: string } | undefined + > = {}, workspaceId: number | null = null, ): NotifySnapshot { const n = new Map(); @@ -264,6 +271,11 @@ export function snapshotOf( 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}`, @@ -273,7 +285,7 @@ export function snapshotOf( directionId: s.directionId, repoId: s.repoId, sessionId: s.info.session_id, - workspaceId: meta?.workspaceId ?? workspaceId ?? undefined, + workspaceId: ws, }, }); } @@ -281,13 +293,30 @@ export function snapshotOf( 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: meta?.workspaceId ?? workspaceId ?? undefined, + workspaceId: ws, }, }); } @@ -409,7 +438,8 @@ export type NotifyOpenIntent = sessionId?: number; } | { type: "needs" } - | { type: "resources" }; + | { type: "resources" } + | { type: "curator" }; export function planNotifyOpen(payload: { kind: string; @@ -419,6 +449,7 @@ export function planNotifyOpen(payload: { 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 @@ -430,6 +461,11 @@ export function planNotifyOpen(payload: { 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) { diff --git a/src/nav/SettingsDialog.tsx b/src/nav/SettingsDialog.tsx index 22a6519e..ea6a1854 100644 --- a/src/nav/SettingsDialog.tsx +++ b/src/nav/SettingsDialog.tsx @@ -102,7 +102,7 @@ export function SettingsScreen() { }, []); useEffect(() => { if (settingsRequestedPage == null) return; - setActive(settingsRequestedPage); + setActive(settingsRequestedPage.page); }, [settingsRequestedPage]); const [query, setQuery] = useState(""); diff --git a/src/state/store.tsx b/src/state/store.tsx index df1ad539..d45e6323 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -403,8 +403,9 @@ interface Store { 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). */ - settingsRequestedPage: SettingsPage | null; + * 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; @@ -657,7 +658,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [repoAnalysisActive, setRepoAnalysisActive] = useState(false); const [homeTab, setHomeTab] = useState("board"); const [settingsInitialPage, setSettingsInitialPage] = useState(null); - const [settingsRequestedPage, setSettingsRequestedPage] = 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"); @@ -1043,11 +1047,18 @@ 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) { - setSettingsRequestedPage(page); + requestPage(page); } return; } @@ -1063,7 +1074,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { setViewing(null); setShowNeeds(false); setSettingsInitialPage(page ?? null); - setSettingsRequestedPage(page ?? null); + if (page != null) { + requestPage(page); + } else { + setSettingsRequestedPage(null); + } setHomeTab("settings"); }, [homeTab, activeThreadId, viewing, showNeeds], diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts index cc0276cf..c2742248 100644 --- a/tests/frontend/notifications.test.ts +++ b/tests/frontend/notifications.test.ts @@ -147,20 +147,60 @@ test("snapshotOf captures stalled workers and leads", () => { directionId: 5, repoId: 8, threadId: 2, + workspaceId: 4, }, }, { 2: { state: "stalled", queue: [] }, 3: { state: "busy", queue: [] } }, null, - { 2: { title: "Issue B" } }, + { 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"); @@ -355,3 +395,18 @@ test("planNotifyOpen carries repo/session for stalled workers", () => { ], ); }); + +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" }, + ], + ); +}); From cf88f877770a95bc44290130ed38b4e51c497f25 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 18:58:08 +0800 Subject: [PATCH 11/35] fix(notify): add open_curator fields to os_notify payloads CI failed because user_info helpers referenced open_curator without the struct fields. --- src-tauri/src/os_notify.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 01a4d45b..742d1083 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -60,6 +60,8 @@ pub struct NotifyOpenPayload { 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. @@ -84,6 +86,8 @@ pub struct NotifySendRequest { pub workspace_id: Option, #[serde(default)] pub open_needs: Option, + #[serde(default)] + pub open_curator: Option, } fn parse_bool(raw: &str) -> Option { @@ -104,6 +108,7 @@ fn payload_from_user_info(info: &HashMap) -> NotifyOpenPayload { 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)), } } From 778b6899a7ae09f4af57f342edbd72085eff068b Mon Sep 17 00:00:00 2001 From: t Date: Wed, 29 Jul 2026 19:05:08 +0800 Subject: [PATCH 12/35] fix(notify): open Repo Map before curator drawer from notifications openCurator now clears issue/worker overlays and switches homeTab to repos so the curator panel is actually visible. --- src/state/store.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/state/store.tsx b/src/state/store.tsx index d45e6323..ecb43721 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -2454,9 +2454,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 From 4231a7c57247a97594480356f79ddec5f1966f1a Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 11:21:30 +0800 Subject: [PATCH 13/35] fix(notify): harden deep-link loading, ownership, and permission state - defer notification clicks while target workspace is still loading - treat OS-revoked notification permission as denied after prior grant - stop stamping active workspace onto global asks without ownership - carry workspace ownership for adopted/live stalled workers - baseline notification sources after initial hydration settles --- src-tauri/src/ask.rs | 4 ++ src-tauri/src/commands.rs | 1 + src-tauri/src/lead_chat/commands.rs | 9 +++ src-tauri/src/os_notify.rs | 9 +++ src/lib/notifications.ts | 99 +++++++++++++++++++++++----- src/lib/notificationsCore.ts | 7 +- src/lib/types.ts | 4 ++ src/state/store.tsx | 81 +++++++++++++++-------- tests/frontend/notifications.test.ts | 28 ++++++++ 9 files changed, 199 insertions(+), 43 deletions(-) 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 84f225c3..7270f095 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 { diff --git a/src-tauri/src/lead_chat/commands.rs b/src-tauri/src/lead_chat/commands.rs index 88a03bdc..ad7bc7be 100644 --- a/src-tauri/src/lead_chat/commands.rs +++ b/src-tauri/src/lead_chat/commands.rs @@ -1086,6 +1086,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,6 +1122,11 @@ async fn build_worker_slots(db: &Db, snaps: Vec) -> Vec) -> Vec Result { 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. if let Ok(guard) = perm_settled().lock() { 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()) } diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 2b222625..3a98465f 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -212,23 +212,31 @@ export async function handleNotifyOpen( openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; openCurator: () => void; activeWorkspaceId: number | null; + workspaceLoading?: boolean; }, ): Promise { await focusMainWindow(); const intents = planNotifyOpen(payload); const workspaceIntent = intents.find((i) => i.type === "workspace"); - if ( - workspaceIntent && - workspaceIntent.type === "workspace" && - workspaceIntent.workspaceId !== deps.activeWorkspaceId - ) { - // Defer the rest until the store finishes the workspace switch effect. - // A second selectWorkspace(activeWorkspaceId) otherwise races and clears - // the deep-link destination. Wait for +2 load-seq bumps (direct call + - // the [activeWorkspaceId] effect) before applying intents. - stashPendingNav(payload, { expectedSeqDelta: 2 }); - await deps.selectWorkspace(workspaceIntent.workspaceId); - return; + 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. Wait for +2 load-seq bumps (direct call + + // the [activeWorkspaceId] effect) before applying intents. + stashPendingNav(payload, { expectedSeqDelta: 2 }); + await deps.selectWorkspace(workspaceIntent.workspaceId); + return; + } + // 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, { expectedSeqDelta: 1 }); + return; + } } await applyNotifyIntents(payload, deps); } @@ -278,6 +286,7 @@ export function useSystemNotifications() { needsByWorkspace, threadWorkspaceById, workspaceLoadSeq, + workspaceLoading, selectWorkspace, goToDirectionRef, openNeeds, @@ -359,6 +368,7 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoading, }); useEffect(() => { navDepsRef.current = { @@ -368,6 +378,7 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoading, }; }, [ selectWorkspace, @@ -376,6 +387,7 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoading, ]); useEffect(() => { @@ -448,6 +460,12 @@ export function useSystemNotifications() { putPendingNav(pending); return; } + if (workspaceLoading) { + // Active workspace id may already match, but selectWorkspace is still + // mid-reset/fetch. Wait for it to finish. + putPendingNav(pending); + return; + } // Resolve relative expectedLoadSeq markers against the seq observed when // the pending route first becomes eligible for the target workspace. if (pending.expectedLoadSeq != null && pending.expectedLoadSeq < 0) { @@ -484,6 +502,7 @@ export function useSystemNotifications() { }, [ activeWorkspaceId, workspaceLoadSeq, + workspaceLoading, goToDirectionRef, openNeeds, openSettings, @@ -500,22 +519,71 @@ export function useSystemNotifications() { void setDockBadge(count); }, [needsByWorkspace]); + // Notification sources hydrate asynchronously after mount / workspace switch. + // Keep baselining (no OS pings) until the initial snapshot for this workspace + // has settled once, so pre-existing quota/needs don't look "new". + const hydratedWs = useRef(null); + const hydrateTimer = useRef | null>(null); + useEffect(() => { + return () => { + if (hydrateTimer.current != null) { + clearTimeout(hydrateTimer.current); + } + }; + }, []); + useEffect(() => { + const sessionRefs: Record< + number, + { + info: { session_id: number }; + status: (typeof sessions)[number]["status"]; + directionId: number; + repoId: number; + threadId: number; + workspaceId?: number; + } + > = {}; + for (const [sid, s] of Object.entries(sessions)) { + sessionRefs[Number(sid)] = { + info: { session_id: s.info.session_id }, + status: s.status, + directionId: s.directionId, + repoId: s.repoId, + threadId: s.threadId, + workspaceId: s.workspaceId, + }; + } const next = snapshotOf( needs, asks, writeTriggers, overview, - sessions, + sessionRefs, leadTurn, processQuota, threadsById.current, activeWorkspaceId, ); - const base = baselineWs.current === activeWorkspaceId ? prev.current : null; + const sameWorkspace = baselineWs.current === activeWorkspaceId; + const base = sameWorkspace ? prev.current : null; prev.current = next; baselineWs.current = activeWorkspaceId; - if (!base) return; // first load / workspace switch: baseline only + + // First pass for a workspace, or while the workspace is still loading: + // only establish/update the baseline. + if (!base || workspaceLoading || hydratedWs.current !== activeWorkspaceId) { + if (hydrateTimer.current != null) { + clearTimeout(hydrateTimer.current); + } + // After sources stop changing briefly, mark this workspace hydrated. + hydrateTimer.current = setTimeout(() => { + if (!workspaceLoading) { + hydratedWs.current = activeWorkspaceId; + } + }, 400); + return; + } if (!notifyEnabled || granted.current !== true) return; if ( isAppInForeground({ @@ -558,6 +626,7 @@ export function useSystemNotifications() { notifyCategories, quietHours, activeWorkspaceId, + workspaceLoading, windowFocused, t, ]); diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index 19f802c9..d4e8150f 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -222,7 +222,10 @@ export function snapshotOf( } for (const a of asks) { const directionId = Number(a.dir); - const ws = threadsById[a.thread]?.workspaceId ?? workspaceId ?? undefined; + // 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: { @@ -230,7 +233,7 @@ export function snapshotOf( threadId: a.thread, directionId: Number.isFinite(directionId) ? directionId : undefined, askId: a.id, - workspaceId: ws, + workspaceId: ws == null ? undefined : ws, openNeeds: true, }, }); diff --git a/src/lib/types.ts b/src/lib/types.ts index b8fafefb..84d8c9b5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -207,6 +207,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 +768,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/state/store.tsx b/src/state/store.tsx index ecb43721..35cd9bff 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -94,6 +94,8 @@ export interface OpenSession { /** the thread this session belongs to (the worker's parent). */ threadId: number; nativeId: string | null; + /** Owning workspace when known (adopted/revived workers may predate a visit). */ + workspaceId?: number; } const SESSION_STATUS: Record = { @@ -211,6 +213,8 @@ interface Store { threadWorkspaceById: Record; /** Bumps after each selectWorkspace finishes loading repos/threads. */ workspaceLoadSeq: number; + /** True while selectWorkspace is mid-fetch/reset for the active workspace. */ + workspaceLoading: boolean; directionsByThread: Record; worktreesByDirection: Record; @@ -605,6 +609,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [threads, setThreads] = useState([]); const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); + const [workspaceLoading, setWorkspaceLoading] = useState(false); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -958,32 +963,40 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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); - rememberThreads(t); - setDirections({}); - setWorktrees({}); - setActiveThreadId(null); - setViewing(null); - setShowNeeds(false); - setHomeTab("board"); - setProposal(null); - setOverview([]); - setWorkspaceLoadSeq((n) => n + 1); + setWorkspaceLoading(true); + 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)]); + setRepos(r); + rememberThreads(t); + setDirections({}); + setWorktrees({}); + setActiveThreadId(null); + setViewing(null); + setShowNeeds(false); + setHomeTab("board"); + setProposal(null); + setOverview([]); + setWorkspaceLoadSeq((n) => n + 1); + } finally { + // Only clear loading if this selection is still the active one. + if (activeWorkspaceIdRef.current === id) { + setWorkspaceLoading(false); + } + } }, [rememberThreads]); const loadThreadChildren = useCallback(async (threadId: number) => { @@ -1492,6 +1505,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { repoId: slot.repo_id, threadId: slot.thread_id, nativeId: slot.info.native_id, + workspaceId: slot.workspace_id ?? undefined, }, }, ); @@ -1534,6 +1548,20 @@ export function StoreProvider({ children }: { children: ReactNode }) { }), ); for (const slot of slots) adoptWorker(slot); + // 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; + }); } while (hydratePendingRef.current); } catch (e) { /* best-effort hydration */ @@ -3156,6 +3184,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { threads, threadWorkspaceById, workspaceLoadSeq, + workspaceLoading, directionsByThread, worktreesByDirection, activeThreadId, diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts index c2742248..d923e91c 100644 --- a/tests/frontend/notifications.test.ts +++ b/tests/frontend/notifications.test.ts @@ -410,3 +410,31 @@ test("planNotifyOpen routes curator stalls to curator surface", () => { ], ); }); + +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); +}); From d9daacfec3ebe2bbbb263a8f4ca1fece9ace1e44 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 11:28:50 +0800 Subject: [PATCH 14/35] fix(notify): include workspace_id in IM Ask test fixtures Ask gained an optional workspace_id field; update outbound test constructors. --- src-tauri/src/im/outbound.rs | 1 + 1 file changed, 1 insertion(+) 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(), } } From 661ed78980cf418929d42ac10de0f20e49483f91 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 15:23:49 +0800 Subject: [PATCH 15/35] fix(notify): close deep-link review races --- src-tauri/src/os_notify.rs | 39 ++++++++------- src/lib/notifications.ts | 97 ++++++++++++++++++-------------------- 2 files changed, 67 insertions(+), 69 deletions(-) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 629e1722..6f2f0e70 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -163,10 +163,12 @@ fn focus_main_window(app: &AppHandle) -> Result<(), String> { Ok(()) } -fn remember_settled_permission(value: &str) { - if let Ok(mut guard) = perm_settled().lock() { - *guard = Some(value.to_string()); - } +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) { @@ -233,23 +235,24 @@ pub async fn os_notify_permission() -> Result { // (NotDetermined vs previously denied). match mgr.get_notification_permission_state().await { Ok(true) => { - remember_settled_permission("granted"); + 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. - if let Ok(guard) = perm_settled().lock() { - 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()); - } + 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()) } @@ -262,11 +265,11 @@ 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"); + remember_settled_permission("granted")?; Ok("granted".to_string()) } Ok(false) => { - remember_settled_permission("denied"); + remember_settled_permission("denied")?; Ok("denied".to_string()) } Err(e) => Err(format!("os_notify request permission: {e}")), diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 3a98465f..bb81b3b1 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -119,23 +119,18 @@ export interface OsNotifyOpenEvent { const PENDING_NAV_KEY = "weft-notify-pending-nav"; type PendingNav = OsNotifyOpenEvent & { - /** - * Absolute workspaceLoadSeq that must be reached before applying intents. - * Negative values encode "currentSeq + abs(value)" and are resolved on first - * settle attempt so we wait for both the direct selectWorkspace load and the - * store's [activeWorkspaceId] re-load. - */ + /** Absolute workspaceLoadSeq that must be reached before applying intents. */ expectedLoadSeq?: number; }; function stashPendingNav( payload: OsNotifyOpenEvent, - opts?: { expectedSeqDelta?: number }, + opts?: { expectedLoadSeq?: number }, ): void { try { const pending: PendingNav = { ...payload }; - if (opts?.expectedSeqDelta != null) { - pending.expectedLoadSeq = -Math.abs(opts.expectedSeqDelta); + if (opts?.expectedLoadSeq != null) { + pending.expectedLoadSeq = opts.expectedLoadSeq; } sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); } catch { @@ -212,6 +207,7 @@ export async function handleNotifyOpen( openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; openCurator: () => void; activeWorkspaceId: number | null; + workspaceLoadSeq: number; workspaceLoading?: boolean; }, ): Promise { @@ -224,9 +220,11 @@ export async function handleNotifyOpen( 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. Wait for +2 load-seq bumps (direct call + - // the [activeWorkspaceId] effect) before applying intents. - stashPendingNav(payload, { expectedSeqDelta: 2 }); + // the deep-link destination. Capture the sequence before starting either + // load: the direct call plus the [activeWorkspaceId] effect each bump it. + stashPendingNav(payload, { + expectedLoadSeq: deps.workspaceLoadSeq + 2, + }); await deps.selectWorkspace(workspaceIntent.workspaceId); return; } @@ -234,7 +232,9 @@ export async function handleNotifyOpen( // or manual switch). Defer until that load finishes so its reset cannot // wipe the deep-link destination. if (deps.workspaceLoading) { - stashPendingNav(payload, { expectedSeqDelta: 1 }); + stashPendingNav(payload, { + expectedLoadSeq: deps.workspaceLoadSeq + 1, + }); return; } } @@ -368,6 +368,7 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoadSeq, workspaceLoading, }); useEffect(() => { @@ -378,6 +379,7 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoadSeq, workspaceLoading, }; }, [ @@ -387,60 +389,64 @@ export function useSystemNotifications() { openSettings, openCurator, activeWorkspaceId, + workspaceLoadSeq, workspaceLoading, ]); useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; - void (async () => { + let takenPending: OsNotifyOpenEvent | null = null; + + const handleOpen = async (payload: OsNotifyOpenEvent) => { + await handleNotifyOpen(payload, navDepsRef.current); + // 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 { - unlisten = await listen("notify://open", (event) => { - void (async () => { - await handleNotifyOpen(event.payload, navDepsRef.current); - // Live delivery was handled — drop any retained pending copy. - try { - await api.osNotifyAckOpen(); - } catch { - /* pure-vite */ - } - })(); - }); + await api.osNotifyAckOpen(); } catch { /* pure-vite */ } - if (cancelled) unlisten?.(); - })(); - return () => { - cancelled = true; - unlisten?.(); }; - }, []); - // Cold-start only: drain a pending open once after mount. If StrictMode - // cancels the first invocation after take(), put the payload back so the - // remount can still consume it. - useEffect(() => { - let cancelled = false; void (async () => { try { + unlisten = await listen("notify://open", (event) => { + void handleOpen(event.payload); + }); + 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; + } + await handleOpen(pending); + takenPending = null; + } catch { + if (cancelled && takenPending) { try { - await api.osNotifyRestorePendingOpen(pending); + await api.osNotifyRestorePendingOpen(takenPending); + takenPending = null; } catch { /* pure-vite / older backend */ } - return; } - await handleNotifyOpen(pending, navDepsRef.current); - } catch { /* pure-vite */ } })(); return () => { cancelled = true; + unlisten?.(); }; }, []); @@ -448,7 +454,6 @@ export function useSystemNotifications() { // selectWorkspace has finished loading (workspaceLoadSeq) so a concurrent // selection reset cannot clear the destination right after we navigate. const pendingAppliedSeq = useRef(null); - const pendingBaselineSeq = useRef(null); useEffect(() => { const pending = takePendingNav(); if (!pending) return; @@ -466,15 +471,6 @@ export function useSystemNotifications() { putPendingNav(pending); return; } - // Resolve relative expectedLoadSeq markers against the seq observed when - // the pending route first becomes eligible for the target workspace. - if (pending.expectedLoadSeq != null && pending.expectedLoadSeq < 0) { - if (pendingBaselineSeq.current == null) { - pendingBaselineSeq.current = workspaceLoadSeq; - } - pending.expectedLoadSeq = - pendingBaselineSeq.current + Math.abs(pending.expectedLoadSeq); - } if ( pending.expectedLoadSeq != null && workspaceLoadSeq < pending.expectedLoadSeq @@ -492,7 +488,6 @@ export function useSystemNotifications() { return; } pendingAppliedSeq.current = workspaceLoadSeq; - pendingBaselineSeq.current = null; void applyNotifyIntents(pending, { goToDirectionRef, openNeeds, From b8c3557633fd8d672c7e5813ba3a630e30a3a25f Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 15:41:06 +0800 Subject: [PATCH 16/35] fix(notify): wait for source hydration --- src-tauri/src/os_notify.rs | 35 +++++++++++++++++++++++- src/lib/notifications.ts | 55 +++++++++++++++++++++----------------- src/state/store.tsx | 39 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 6f2f0e70..0b62c23b 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -315,6 +315,15 @@ pub fn os_notify_ack_open() -> Result<(), String> { Ok(()) } +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] @@ -322,7 +331,9 @@ pub fn os_notify_restore_pending_open(payload: NotifyOpenPayload) -> Result<(), let mut guard = pending_open() .lock() .map_err(|e| format!("os_notify pending lock poisoned: {e}"))?; - *guard = Some(payload); + // 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(()) } @@ -375,4 +386,26 @@ mod tests { }; 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")); + } } diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index bb81b3b1..690a4578 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -117,10 +117,13 @@ export interface OsNotifyOpenEvent { /** 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( @@ -129,6 +132,7 @@ function stashPendingNav( ): void { try { const pending: PendingNav = { ...payload }; + pending.loadGeneration = NAV_RUNTIME_GENERATION; if (opts?.expectedLoadSeq != null) { pending.expectedLoadSeq = opts.expectedLoadSeq; } @@ -278,6 +282,7 @@ export function useSystemNotifications() { sessions, leadTurn, processQuota, + notificationHydration, threads, notifyEnabled, notifyCategories, @@ -347,8 +352,12 @@ export function useSystemNotifications() { const focused = await win.isFocused(); if (!cancelled) setWindowFocused(focused); unFocus = await win.onFocusChanged(({ payload }) => { - setWindowFocused(payload); + if (!cancelled) setWindowFocused(payload); }); + if (cancelled) { + unFocus(); + unFocus = undefined; + } } catch { if (!cancelled) setWindowFocused(null); } @@ -465,6 +474,13 @@ export function useSystemNotifications() { 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. @@ -515,18 +531,14 @@ export function useSystemNotifications() { }, [needsByWorkspace]); // Notification sources hydrate asynchronously after mount / workspace switch. - // Keep baselining (no OS pings) until the initial snapshot for this workspace - // has settled once, so pre-existing quota/needs don't look "new". - const hydratedWs = useRef(null); - const hydrateTimer = useRef | null>(null); - useEffect(() => { - return () => { - if (hydrateTimer.current != null) { - clearTimeout(hydrateTimer.current); - } - }; - }, []); - + // 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. + const sourcesHydrated = + notificationHydration.workspaceId === activeWorkspaceId && + notificationHydration.needs && + notificationHydration.overview && + notificationHydration.quota && + notificationHydration.liveWorkers; useEffect(() => { const sessionRefs: Record< number, @@ -565,18 +577,9 @@ export function useSystemNotifications() { prev.current = next; baselineWs.current = activeWorkspaceId; - // First pass for a workspace, or while the workspace is still loading: - // only establish/update the baseline. - if (!base || workspaceLoading || hydratedWs.current !== activeWorkspaceId) { - if (hydrateTimer.current != null) { - clearTimeout(hydrateTimer.current); - } - // After sources stop changing briefly, mark this workspace hydrated. - hydrateTimer.current = setTimeout(() => { - if (!workspaceLoading) { - hydratedWs.current = activeWorkspaceId; - } - }, 400); + // First pass for a workspace, while it is loading, or before every initial + // source has completed: only establish/update the silent baseline. + if (!base || workspaceLoading || !sourcesHydrated) { return; } if (!notifyEnabled || granted.current !== true) return; @@ -617,6 +620,7 @@ export function useSystemNotifications() { sessions, leadTurn, processQuota, + notificationHydration, notifyEnabled, notifyCategories, quietHours, @@ -624,5 +628,6 @@ export function useSystemNotifications() { workspaceLoading, windowFocused, t, + sourcesHydrated, ]); } diff --git a/src/state/store.tsx b/src/state/store.tsx index 35cd9bff..f633f39d 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -73,6 +73,13 @@ import type { export type HomeTab = "board" | "repos" | "settings"; export type ThreadTab = "lead" | "board"; +export interface NotificationHydration { + workspaceId: number | null; + needs: boolean; + overview: boolean; + quota: boolean; + liveWorkers: boolean; +} /** 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). */ @@ -308,6 +315,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; @@ -632,6 +641,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { const activeThreadIdRef = useRef(activeThreadId); activeThreadIdRef.current = activeThreadId; const [sessions, setSessions] = useState>({}); + const [notificationHydration, setNotificationHydration] = useState({ + workspaceId: null, + needs: false, + overview: false, + quota: false, + liveWorkers: false, + }); const [checksByDirection, setChecksByDirection] = useState>({}); const [checkingDirections, setCheckingDirections] = useState>({}); // Directions with an auto-(re)dispatch in flight, so the poll-driven effect @@ -774,6 +790,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const refreshProcessQuota = useCallback(async () => { try { applyProcessQuota(await api.processQuotaStatus()); + setNotificationHydration((current) => ({ ...current, quota: true })); } catch (error) { // Pure Vite dev and an older backend do not expose the governor command. if (import.meta.env.DEV) console.error(error); @@ -782,6 +799,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { const unlisten = listen("process-quota://changed", (event) => { applyProcessQuota(event.payload); + setNotificationHydration((current) => ({ ...current, quota: true })); }); void refreshProcessQuota(); return () => { @@ -964,6 +982,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { const selectWorkspace = useCallback(async (id: number) => { setActiveWorkspaceId(id); setWorkspaceLoading(true); + setNotificationHydration((current) => ({ + ...current, + workspaceId: id, + 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 @@ -1044,6 +1068,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { // drop this stale response instead of overwriting the fresher state. if (reqId !== overviewReqRef.current) return; setOverview(data); + setNotificationHydration((current) => { + if (current.workspaceId !== activeWorkspaceId) return current; + return { ...current, overview: true }; + }); } catch (e) { /* ignore */ console.error(e); @@ -1563,6 +1591,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { return changed ? next : prev; }); } while (hydratePendingRef.current); + setNotificationHydration((current) => ({ ...current, liveWorkers: true })); } catch (e) { /* best-effort hydration */ console.error(e); @@ -2331,10 +2360,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { const refreshNeeds = useCallback(async () => { // Permission Asks are global (not workspace-scoped); always refresh them. + let notificationNeedsReady = true; try { setAsks(await api.pendingAsks()); } catch (e) { /* server may not be ready */ + notificationNeedsReady = false; console.error(e); } // Standing grants are global too; refresh so "inherited access" markers stay @@ -2363,6 +2394,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setWriteTriggers(await api.writeTriggers(activeWorkspaceId)); } catch (e) { /* bus may not be ready */ + notificationNeedsReady = false; console.error(e); } // per-workspace counts so the switcher can flag OTHER workspaces. @@ -2372,6 +2404,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { /* ignore */ console.error(e); } + if (notificationNeedsReady && activeWorkspaceIdRef.current === activeWorkspaceId) { + setNotificationHydration((current) => { + if (current.workspaceId !== activeWorkspaceId) return current; + return { ...current, needs: true }; + }); + } }, [activeWorkspaceId]); const openNeeds = useCallback(() => { @@ -3242,6 +3280,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setGuardrails, processQuota, refreshProcessQuota, + notificationHydration, needs, asks, authGrants, From 161b7b879314fb0f00391982c4f27c4b4d400d08 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 15:52:38 +0800 Subject: [PATCH 17/35] fix(notify): close hydration and routing edges --- src/lib/notifications.ts | 22 ++++++++++++++++------ src/state/store.tsx | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 690a4578..0d73824d 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -301,7 +301,7 @@ export function useSystemNotifications() { const { t } = useTranslation(); const prev = useRef(null); const baselineWs = useRef(null); - const granted = useRef(null); + const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -338,7 +338,7 @@ export function useSystemNotifications() { useEffect(() => { if (!notifyEnabled) return; void ensureNotifyPermission().then((p) => { - granted.current = p === "granted"; + setPermissionState(p); }); }, [notifyEnabled]); @@ -352,7 +352,13 @@ export function useSystemNotifications() { const focused = await win.isFocused(); if (!cancelled) setWindowFocused(focused); unFocus = await win.onFocusChanged(({ payload }) => { - if (!cancelled) setWindowFocused(payload); + if (cancelled) return; + setWindowFocused(payload); + if (payload && notifyEnabled) { + void notifyPermission().then((p) => { + if (!cancelled) setPermissionState(p); + }); + } }); if (cancelled) { unFocus(); @@ -366,7 +372,7 @@ export function useSystemNotifications() { cancelled = true; unFocus?.(); }; - }, []); + }, [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. @@ -539,6 +545,7 @@ export function useSystemNotifications() { notificationHydration.overview && notificationHydration.quota && notificationHydration.liveWorkers; + const sourcesJustHydrated = useRef(false); useEffect(() => { const sessionRefs: Record< number, @@ -576,13 +583,15 @@ export function useSystemNotifications() { const base = sameWorkspace ? prev.current : null; prev.current = next; baselineWs.current = activeWorkspaceId; + const becameHydrated = sourcesHydrated && !sourcesJustHydrated.current; + sourcesJustHydrated.current = sourcesHydrated; // First pass for a workspace, while it is loading, or before every initial // source has completed: only establish/update the silent baseline. - if (!base || workspaceLoading || !sourcesHydrated) { + if (!base || workspaceLoading || !sourcesHydrated || becameHydrated) { return; } - if (!notifyEnabled || granted.current !== true) return; + if (!notifyEnabled || permissionState !== "granted") return; if ( isAppInForeground({ windowFocused, @@ -629,5 +638,6 @@ export function useSystemNotifications() { windowFocused, t, sourcesHydrated, + permissionState, ]); } diff --git a/src/state/store.tsx b/src/state/store.tsx index f633f39d..669dddf8 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -2999,7 +2999,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { (s) => s.directionId === directionId && s.repoId === opts.repoId, ); } - if (!live && Number.isFinite(directionId)) { + if (!live && opts?.repoId == null && Number.isFinite(directionId)) { live = all.find((s) => s.directionId === directionId); } if (live) { From 728097d64675e85fc95b9a69360685855b1335f3 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 16:08:31 +0800 Subject: [PATCH 18/35] fix(notify): close concurrent load review gaps --- src-tauri/src/commands.rs | 12 ++++++++---- src/state/store.tsx | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7270f095..d089f02d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2370,13 +2370,14 @@ 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 { // Questions still come from open_answerable_ask_count (issue #105). @@ -2396,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/state/store.tsx b/src/state/store.tsx index 669dddf8..2d1d7bed 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -619,6 +619,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); const [workspaceLoading, setWorkspaceLoading] = useState(false); + const workspaceLoadCountRef = useRef(0); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -980,6 +981,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []); const selectWorkspace = useCallback(async (id: number) => { + workspaceLoadCountRef.current += 1; setActiveWorkspaceId(id); setWorkspaceLoading(true); setNotificationHydration((current) => ({ @@ -1013,13 +1015,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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 }; + }); setWorkspaceLoadSeq((n) => n + 1); } finally { - // Only clear loading if this selection is still the active one. - if (activeWorkspaceIdRef.current === id) { - setWorkspaceLoading(false); - } + workspaceLoadCountRef.current -= 1; + if (workspaceLoadCountRef.current === 0) setWorkspaceLoading(false); } }, [rememberThreads]); From d5149fe8eaf5eb30391df062abe31e00b77047e9 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 16:24:10 +0800 Subject: [PATCH 19/35] fix(notify): harden hydration and click delivery --- src/lib/notifications.ts | 54 +++++++++++++++++++++++++++-------- src/state/store.tsx | 61 +++++++++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 0d73824d..d15acdab 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -115,6 +115,20 @@ export interface OsNotifyOpenEvent { 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)}`; @@ -412,23 +426,39 @@ export function useSystemNotifications() { let unlisten: (() => void) | undefined; let cancelled = false; let takenPending: OsNotifyOpenEvent | null = null; - - const handleOpen = async (payload: OsNotifyOpenEvent) => { - await handleNotifyOpen(payload, navDepsRef.current); - // 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(); - } catch { - /* pure-vite */ - } + const openInFlight = new Map>(); + + const handleOpen = (payload: OsNotifyOpenEvent): Promise => { + const key = notifyOpenKey(payload); + const existing = openInFlight.get(key); + if (existing) return existing; + const task = (async () => { + await handleNotifyOpen(payload, navDepsRef.current); + // 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(); + } catch { + /* pure-vite */ + } + })(); + 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 { unlisten = await listen("notify://open", (event) => { - void handleOpen(event.payload); + void handleOpen(event.payload).catch(() => undefined); }); if (cancelled) { unlisten?.(); diff --git a/src/state/store.tsx b/src/state/store.tsx index 2d1d7bed..f9f97c9f 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -80,6 +80,8 @@ export interface NotificationHydration { quota: 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). */ @@ -674,6 +676,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { }); const [writeTriggers, setWriteTriggers] = useState([]); const [needsByWorkspace, setNeedsByWorkspace] = useState>({}); + const needsRefreshSeqRef = useRef(0); const [showNeeds, setShowNeeds] = useState(false); const [repoProfiles, setRepoProfiles] = useState([]); const [repoEdges, setRepoEdges] = useState([]); @@ -776,6 +779,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [processQuota, setProcessQuota] = useState(null); const processQuotaRef = useRef(null); + const processQuotaHydratedRef = useRef(false); const applyProcessQuota = useCallback((next: ProcessQuotaStatus) => { const previous = processQuotaRef.current; if (!shouldApplyProcessQuotaStatus(previous, next)) return; @@ -790,7 +794,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []); const refreshProcessQuota = useCallback(async () => { try { - applyProcessQuota(await api.processQuotaStatus()); + const next = await api.processQuotaStatus(); + applyProcessQuota(next); + processQuotaHydratedRef.current = true; setNotificationHydration((current) => ({ ...current, quota: true })); } catch (error) { // Pure Vite dev and an older backend do not expose the governor command. @@ -800,11 +806,16 @@ export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { const unlisten = listen("process-quota://changed", (event) => { applyProcessQuota(event.payload); + processQuotaHydratedRef.current = true; setNotificationHydration((current) => ({ ...current, quota: true })); }); void refreshProcessQuota(); + const retryId = setInterval(() => { + if (!processQuotaHydratedRef.current) void refreshProcessQuota(); + }, NOTIFICATION_SOURCE_RETRY_MS); return () => { void unlisten.then((stop) => stop()); + clearInterval(retryId); }; }, [applyProcessQuota, refreshProcessQuota]); @@ -1024,8 +1035,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { if (current.workspaceId !== id) return current; return { ...current, overview: false }; }); - setWorkspaceLoadSeq((n) => n + 1); } finally { + // Deep-link markers count every settled load, including a rejected + // listRepos/listThreads request, so a failed sibling cannot leave a + // pending click waiting for an impossible sequence number. + setWorkspaceLoadSeq((n) => n + 1); workspaceLoadCountRef.current -= 1; if (workspaceLoadCountRef.current === 0) setWorkspaceLoading(false); } @@ -1555,6 +1569,7 @@ 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 liveWorkersHydratedRef = useRef(false); const hydrateLiveWorkers = useCallback(async () => { if (hydratingRef.current) { hydratePendingRef.current = true; @@ -1599,6 +1614,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { return changed ? next : prev; }); } while (hydratePendingRef.current); + liveWorkersHydratedRef.current = true; setNotificationHydration((current) => ({ ...current, liveWorkers: true })); } catch (e) { /* best-effort hydration */ @@ -1619,6 +1635,9 @@ 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()); if (cancelled) { @@ -1632,6 +1651,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; un?.(); + clearInterval(retryId); }; }, [hydrateLiveWorkers]); @@ -2367,10 +2387,14 @@ export function StoreProvider({ children }: { children: ReactNode }) { ); const refreshNeeds = useCallback(async () => { + const workspaceId = activeWorkspaceId; + const requestId = ++needsRefreshSeqRef.current; + const isLatestRequest = () => requestId === needsRefreshSeqRef.current; // Permission Asks are global (not workspace-scoped); always refresh them. let notificationNeedsReady = true; try { - setAsks(await api.pendingAsks()); + const nextAsks = await api.pendingAsks(); + if (isLatestRequest()) setAsks(nextAsks); } catch (e) { /* server may not be ready */ notificationNeedsReady = false; @@ -2379,7 +2403,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); } @@ -2388,18 +2413,27 @@ 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([]); + } 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); + } } catch (e) { /* bus may not be ready */ notificationNeedsReady = false; @@ -2407,14 +2441,15 @@ export function StoreProvider({ children }: { children: ReactNode }) { } // 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); } - if (notificationNeedsReady && activeWorkspaceIdRef.current === activeWorkspaceId) { + if (isLatestRequest() && notificationNeedsReady && activeWorkspaceIdRef.current === workspaceId) { setNotificationHydration((current) => { - if (current.workspaceId !== activeWorkspaceId) return current; + if (current.workspaceId !== workspaceId) return current; return { ...current, needs: true }; }); } From 7a018ab56aefe8072ae3c08ddc42b8b041b8d92b Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 16:38:32 +0800 Subject: [PATCH 20/35] fix(notify): preserve click ownership across races --- src-tauri/src/os_notify.rs | 41 ++++++++++++++++++++++++++++++++++--- src/lib/api.ts | 12 ++++++++++- src/lib/notifications.ts | 42 +++++++++++++++++++++++++++++++++----- src/state/store.tsx | 3 +++ 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/os_notify.rs b/src-tauri/src/os_notify.rs index 0b62c23b..a7e2b581 100644 --- a/src-tauri/src/os_notify.rs +++ b/src-tauri/src/os_notify.rs @@ -42,7 +42,7 @@ fn pending_open() -> &'static Mutex> { } /// Payload the frontend stores in `user_info` and gets back on click. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct NotifyOpenPayload { pub kind: String, @@ -307,14 +307,23 @@ pub fn os_notify_take_pending_open() -> Result, String /// Clear a retained pending open after the frontend has handled a live event. #[tauri::command] -pub fn os_notify_ack_open() -> Result<(), String> { +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}"))?; - *guard = None; + 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, @@ -408,4 +417,30 @@ mod tests { 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/lib/api.ts b/src/lib/api.ts index e9d41252..3b345604 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -364,7 +364,17 @@ export const api = { // Desktop OS notifications via user-notify (click deep-link capable). osNotifyPermission: () => invoke("os_notify_permission"), osNotifyRequestPermission: () => invoke("os_notify_request_permission"), - osNotifyAckOpen: () => invoke("os_notify_ack_open"), + 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; diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index d15acdab..7b76c34b 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -438,7 +438,7 @@ export function useSystemNotifications() { // handled. This also prevents a live event from being drained again by // the ordered cold-start check below. try { - await api.osNotifyAckOpen(); + await api.osNotifyAckOpen(payload); } catch { /* pure-vite */ } @@ -498,8 +498,20 @@ export function useSystemNotifications() { // 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 pendingAppliedSeq = useRef(null); + 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 ( @@ -530,7 +542,11 @@ export function useSystemNotifications() { putPendingNav(pending); return; } - if (pendingAppliedSeq.current === workspaceLoadSeq) { + const applicationKey = `${workspaceLoadSeq}:${notifyOpenKey(pending)}`; + if ( + pendingAppliedKey.current === applicationKey || + pendingApplyingKey.current === applicationKey + ) { // Already applied for this load. return; } @@ -539,13 +555,28 @@ export function useSystemNotifications() { putPendingNav(pending); return; } - pendingAppliedSeq.current = workspaceLoadSeq; + pendingApplyingKey.current = applicationKey; void applyNotifyIntents(pending, { goToDirectionRef, openNeeds, openSettings, openCurator, - }); + }).then( + () => { + pendingApplyingKey.current = null; + pendingAppliedKey.current = applicationKey; + }, + () => { + pendingApplyingKey.current = null; + putPendingNav(pending); + if (pendingNavRetryTimer.current == null) { + pendingNavRetryTimer.current = setTimeout(() => { + pendingNavRetryTimer.current = null; + setPendingNavRetry((n) => n + 1); + }, 1_000); + } + } + ); }, [ activeWorkspaceId, workspaceLoadSeq, @@ -554,6 +585,7 @@ export function useSystemNotifications() { openNeeds, openSettings, openCurator, + pendingNavRetry, ]); // Dock / taskbar badge tracks actionable Needs-you across all workspaces. diff --git a/src/state/store.tsx b/src/state/store.tsx index f9f97c9f..2f0ca6a9 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -622,6 +622,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); const [workspaceLoading, setWorkspaceLoading] = useState(false); const workspaceLoadCountRef = useRef(0); + const workspaceSelectionGenerationRef = useRef(0); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -992,6 +993,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []); const selectWorkspace = useCallback(async (id: number) => { + const selectionGeneration = ++workspaceSelectionGenerationRef.current; workspaceLoadCountRef.current += 1; setActiveWorkspaceId(id); setWorkspaceLoading(true); @@ -1017,6 +1019,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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({}); From 10be1bd86a5d2989a11dcc7e619cdb0dede7457c Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 16:58:04 +0800 Subject: [PATCH 21/35] fix(notify): align needs and refresh ownership --- src-tauri/src/lead_chat/commands.rs | 3 ++ src/components/NeedsDock.tsx | 24 ++++++---- src/lib/notifications.ts | 9 +++- src/lib/types.ts | 1 + src/state/store.tsx | 69 ++++++++++++++++++++--------- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/lead_chat/commands.rs b/src-tauri/src/lead_chat/commands.rs index ad7bc7be..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, @@ -1130,6 +1131,7 @@ async fn build_worker_slots(db: &Db, snaps: Vec) -> Vec @@ -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/lib/notifications.ts b/src/lib/notifications.ts index 7b76c34b..5a21c08b 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -256,6 +256,13 @@ export async function handleNotifyOpen( return; } } + // 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; + } await applyNotifyIntents(payload, deps); } @@ -478,7 +485,7 @@ export function useSystemNotifications() { await handleOpen(pending); takenPending = null; } catch { - if (cancelled && takenPending) { + if (takenPending) { try { await api.osNotifyRestorePendingOpen(takenPending); takenPending = null; diff --git a/src/lib/types.ts b/src/lib/types.ts index 84d8c9b5..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; diff --git a/src/state/store.tsx b/src/state/store.tsx index 2f0ca6a9..8e22b0ba 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -121,31 +121,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< @@ -678,6 +676,8 @@ 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 [showNeeds, setShowNeeds] = useState(false); const [repoProfiles, setRepoProfiles] = useState([]); const [repoEdges, setRepoEdges] = useState([]); @@ -1507,7 +1507,10 @@ 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, }, }; @@ -1515,7 +1518,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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 @@ -2389,8 +2392,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { [activeThreadId], ); - const refreshNeeds = useCallback(async () => { - const workspaceId = activeWorkspaceId; + 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. @@ -2456,7 +2459,31 @@ export function StoreProvider({ children }: { children: ReactNode }) { return { ...current, needs: true }; }); } - }, [activeWorkspaceId]); + }, []); + + const refreshNeeds = useCallback(async () => { + const inFlight = needsRefreshInFlightRef.current; + if (inFlight) { + needsRefreshPendingRef.current = true; + await inFlight; + return; + } + const run = async () => { + do { + needsRefreshPendingRef.current = false; + await refreshNeedsOnce(); + } while (needsRefreshPendingRef.current); + }; + const current = run(); + needsRefreshInFlightRef.current = current; + try { + await current; + } finally { + if (needsRefreshInFlightRef.current === current) { + needsRefreshInFlightRef.current = null; + } + } + }, [refreshNeedsOnce]); const openNeeds = useCallback(() => { setViewing(null); From 76f4d9fae4f04a71a3890affdbefaf1c7d5f1624 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 17:16:11 +0800 Subject: [PATCH 22/35] fix(notify): guard deferred workspace navigation --- src/lib/notifications.ts | 64 +++++++++++++++++++++++++++++++++++++++- src/state/store.tsx | 59 ++++++++++++++++++++++++++++-------- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 5a21c08b..4532a360 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -175,6 +175,20 @@ function putPendingNav(pending: PendingNav): void { } } +/** 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 */ + } +} + export async function applyNotifyIntents( payload: OsNotifyOpenEvent, deps: { @@ -227,6 +241,8 @@ export async function handleNotifyOpen( activeWorkspaceId: number | null; workspaceLoadSeq: number; workspaceLoading?: boolean; + workspaceLoadReady?: boolean; + needsHydrated?: boolean; }, ): Promise { await focusMainWindow(); @@ -256,6 +272,25 @@ export async function handleNotifyOpen( return; } } + 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; + } + const needsIntent = intents.some((intent) => intent.type === "needs"); + if (needsIntent && deps.needsHydrated === false) { + stashPendingNav(payload); + return; + } // 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. @@ -313,12 +348,16 @@ export function useSystemNotifications() { threadWorkspaceById, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, selectWorkspace, goToDirectionRef, openNeeds, openSettings, openCurator, } = useStore(); + const needsHydrated = + notificationHydration.workspaceId === activeWorkspaceId && + notificationHydration.needs; const { t } = useTranslation(); const prev = useRef(null); const baselineWs = useRef(null); @@ -406,6 +445,8 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, + needsHydrated, }); useEffect(() => { navDepsRef.current = { @@ -417,6 +458,8 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, + needsHydrated, }; }, [ selectWorkspace, @@ -427,6 +470,8 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, + needsHydrated, ]); useEffect(() => { @@ -542,6 +587,12 @@ export function useSystemNotifications() { 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 @@ -562,6 +613,15 @@ export function useSystemNotifications() { putPendingNav(pending); return; } + 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; + } pendingApplyingKey.current = applicationKey; void applyNotifyIntents(pending, { goToDirectionRef, @@ -575,7 +635,7 @@ export function useSystemNotifications() { }, () => { pendingApplyingKey.current = null; - putPendingNav(pending); + putPendingNavIfUnchanged(pending); if (pendingNavRetryTimer.current == null) { pendingNavRetryTimer.current = setTimeout(() => { pendingNavRetryTimer.current = null; @@ -588,6 +648,8 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, + needsHydrated, goToDirectionRef, openNeeds, openSettings, diff --git a/src/state/store.tsx b/src/state/store.tsx index 8e22b0ba..ee241448 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -222,6 +222,8 @@ interface Store { workspaceLoadSeq: number; /** True while selectWorkspace is mid-fetch/reset for the active workspace. */ workspaceLoading: boolean; + /** True only after the latest workspace selection committed repos and threads. */ + workspaceLoadReady: boolean; directionsByThread: Record; worktreesByDirection: Record; @@ -619,6 +621,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [workspaceLoadReady, setWorkspaceLoadReady] = useState(false); const workspaceLoadCountRef = useRef(0); const workspaceSelectionGenerationRef = useRef(0); const rememberThreads = useCallback((list: Thread[]) => { @@ -678,6 +681,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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([]); @@ -997,6 +1002,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { workspaceLoadCountRef.current += 1; setActiveWorkspaceId(id); 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, @@ -1038,10 +1048,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { if (current.workspaceId !== id) return current; return { ...current, overview: false }; }); + setWorkspaceLoadReady(true); } finally { - // Deep-link markers count every settled load, including a rejected - // listRepos/listThreads request, so a failed sibling cannot leave a - // pending click waiting for an impossible sequence number. + // 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); workspaceLoadCountRef.current -= 1; if (workspaceLoadCountRef.current === 0) setWorkspaceLoading(false); @@ -1205,6 +1216,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } setActiveWorkspaceId(null); + setWorkspaceLoadReady(false); setRepos([]); setThreads([]); setDirections({}); @@ -2461,29 +2473,40 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, []); + 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 run = async () => { - do { - needsRefreshPendingRef.current = false; - await refreshNeedsOnce(); - } while (needsRefreshPendingRef.current); - }; - const current = run(); + const current = refreshNeedsOnce(); needsRefreshInFlightRef.current = current; try { await current; } finally { - if (needsRefreshInFlightRef.current === current) { - needsRefreshInFlightRef.current = null; + if (needsRefreshInFlightRef.current !== current) return; + needsRefreshInFlightRef.current = null; + if (needsRefreshPendingRef.current) { + needsRefreshPendingRef.current = false; + scheduleTrailingNeedsRefresh(); } } - }, [refreshNeedsOnce]); + }, [refreshNeedsOnce, scheduleTrailingNeedsRefresh]); + refreshNeedsRef.current = refreshNeeds; const openNeeds = useCallback(() => { setViewing(null); @@ -3135,6 +3158,15 @@ 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(() => { @@ -3296,6 +3328,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { threadWorkspaceById, workspaceLoadSeq, workspaceLoading, + workspaceLoadReady, directionsByThread, worktreesByDirection, activeThreadId, From a67df3ef77a2e89f7dcd447f571fbcb2790effc0 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 17:31:04 +0800 Subject: [PATCH 23/35] fix(notify): preserve category events during hydration --- src/lib/notifications.ts | 69 +++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 4532a360..35990b82 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -7,11 +7,14 @@ import { useStore } from "../state/store"; import { api } from "./api"; import { diffForNotifications, + emptyNotifySnapshot, isAppInForeground, isInQuietHours, + NOTIFY_CATEGORIES, notifyCopyKeys, planNotifyOpen, snapshotOf, + type NotifyCategory, type NotifyRoute, type NotifySnapshot, } from "./notificationsCore"; @@ -361,6 +364,13 @@ export function useSystemNotifications() { const { t } = useTranslation(); const prev = useRef(null); const baselineWs = useRef(null); + const baselineReady = useRef>(new Set()); + const sourceReadyPrevious = useRef>({ + needs: false, + review: false, + stalled: false, + quota: false, + }); const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -670,13 +680,8 @@ export function useSystemNotifications() { // 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. - const sourcesHydrated = - notificationHydration.workspaceId === activeWorkspaceId && - notificationHydration.needs && - notificationHydration.overview && - notificationHydration.quota && - notificationHydration.liveWorkers; - const sourcesJustHydrated = useRef(false); + // Each category keeps its own baseline so a ready source can accumulate real + // events while an unrelated source is still retrying. useEffect(() => { const sessionRefs: Record< number, @@ -710,18 +715,46 @@ export function useSystemNotifications() { threadsById.current, activeWorkspaceId, ); - const sameWorkspace = baselineWs.current === activeWorkspaceId; - const base = sameWorkspace ? prev.current : null; - prev.current = next; - baselineWs.current = activeWorkspaceId; - const becameHydrated = sourcesHydrated && !sourcesJustHydrated.current; - sourcesJustHydrated.current = sourcesHydrated; - - // First pass for a workspace, while it is loading, or before every initial - // source has completed: only establish/update the silent baseline. - if (!base || workspaceLoading || !sourcesHydrated || becameHydrated) { + const sourceWorkspaceMatches = + notificationHydration.workspaceId === activeWorkspaceId; + const sourceReady: Record = { + needs: sourceWorkspaceMatches && notificationHydration.needs, + review: sourceWorkspaceMatches && notificationHydration.overview, + stalled: sourceWorkspaceMatches && notificationHydration.liveWorkers, + quota: sourceWorkspaceMatches && notificationHydration.quota, + }; + const sameWorkspace = + baselineWs.current === activeWorkspaceId && prev.current != null; + const sourceBecameUnready = NOTIFY_CATEGORIES.some( + (kind) => sourceReadyPrevious.current[kind] && !sourceReady[kind], + ); + if (!sameWorkspace || sourceBecameUnready) { + baselineWs.current = activeWorkspaceId; + prev.current = emptyNotifySnapshot(); + baselineReady.current.clear(); + } + sourceReadyPrevious.current = sourceReady; + const base = prev.current; + if (!base) return; + for (const kind of NOTIFY_CATEGORIES) { + if (sourceReady[kind] && !baselineReady.current.has(kind)) { + base[kind] = new Map(next[kind]); + baselineReady.current.add(kind); + } + } + const allSourcesHydrated = NOTIFY_CATEGORIES.every( + (kind) => sourceReady[kind], + ); + if (!allSourcesHydrated) { + // Keep ready categories' old baselines; newly arrived entries will be + // compared once the unrelated source also becomes authoritative. return; } + const events = diffForNotifications(base, next, notifyCategories); + // Advance every category before the foreground/quiet-hours gate, preserving + // the existing behavior that suppressed events are not replayed later. + prev.current = next; + if (workspaceLoading) return; if (!notifyEnabled || permissionState !== "granted") return; if ( isAppInForeground({ @@ -733,7 +766,6 @@ export function useSystemNotifications() { } if (isInQuietHours(quietHours)) return; - const events = diffForNotifications(base, next, notifyCategories); if (events.length === 0) return; void (async () => { @@ -768,7 +800,6 @@ export function useSystemNotifications() { workspaceLoading, windowFocused, t, - sourcesHydrated, permissionState, ]); } From b14bfe70f55592a116fac0ecbd0e3bc71dc682b1 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 17:50:25 +0800 Subject: [PATCH 24/35] fix(notify): preserve live stall transitions --- src/lib/notifications.ts | 63 ++++++++++++++++++--- src/lib/notificationsCore.ts | 2 + src/state/store.tsx | 107 ++++++++++++++++++++++------------- 3 files changed, 124 insertions(+), 48 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 35990b82..c5d5ec94 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +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"; @@ -371,6 +371,7 @@ export function useSystemNotifications() { stalled: false, quota: false, }); + const liveWorkersReadyPrevious = useRef(false); const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -458,9 +459,21 @@ export function useSystemNotifications() { workspaceLoadReady, needsHydrated, }); + 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.workspaceLoadReady = false; + await selectWorkspace(id); + }, + [selectWorkspace], + ); useEffect(() => { navDepsRef.current = { - selectWorkspace, + selectWorkspace: navigationSelectWorkspace, goToDirectionRef, openNeeds, openSettings, @@ -472,7 +485,7 @@ export function useSystemNotifications() { needsHydrated, }; }, [ - selectWorkspace, + navigationSelectWorkspace, goToDirectionRef, openNeeds, openSettings, @@ -489,12 +502,13 @@ export function useSystemNotifications() { let cancelled = false; let takenPending: OsNotifyOpenEvent | null = null; const openInFlight = new Map>(); + let openTail: Promise = Promise.resolve(); const handleOpen = (payload: OsNotifyOpenEvent): Promise => { const key = notifyOpenKey(payload); const existing = openInFlight.get(key); if (existing) return existing; - const task = (async () => { + const task = openTail.then(async () => { await handleNotifyOpen(payload, navDepsRef.current); // Native delivery retains the payload until the frontend confirms it was // handled. This also prevents a live event from being drained again by @@ -504,7 +518,8 @@ export function useSystemNotifications() { } catch { /* pure-vite */ } - })(); + }); + openTail = task.catch(() => undefined); openInFlight.set(key, task); void task.then( () => { @@ -683,6 +698,10 @@ export function useSystemNotifications() { // 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 liveWorkersReady = + sourceWorkspaceMatches && notificationHydration.liveWorkers; const sessionRefs: Record< number, { @@ -691,16 +710,19 @@ export function useSystemNotifications() { 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, }; } @@ -715,16 +737,32 @@ export function useSystemNotifications() { threadsById.current, activeWorkspaceId, ); - const sourceWorkspaceMatches = - notificationHydration.workspaceId === activeWorkspaceId; const sourceReady: Record = { needs: sourceWorkspaceMatches && notificationHydration.needs, review: sourceWorkspaceMatches && notificationHydration.overview, - stalled: sourceWorkspaceMatches && notificationHydration.liveWorkers, + // Lead-turn pushes and locally observed sessions are authoritative even + // while the adopted-worker snapshot is retrying. + stalled: sourceWorkspaceMatches, quota: sourceWorkspaceMatches && 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 liveWorkersBecameReady = + liveWorkersReady && !liveWorkersReadyPrevious.current; const sourceBecameUnready = NOTIFY_CATEGORIES.some( (kind) => sourceReadyPrevious.current[kind] && !sourceReady[kind], ); @@ -732,8 +770,10 @@ export function useSystemNotifications() { baselineWs.current = activeWorkspaceId; prev.current = emptyNotifySnapshot(); baselineReady.current.clear(); + liveWorkersReadyPrevious.current = false; } sourceReadyPrevious.current = sourceReady; + liveWorkersReadyPrevious.current = liveWorkersReady; const base = prev.current; if (!base) return; for (const kind of NOTIFY_CATEGORIES) { @@ -742,6 +782,13 @@ export function useSystemNotifications() { baselineReady.current.add(kind); } } + 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 allSourcesHydrated = NOTIFY_CATEGORIES.every( (kind) => sourceReady[kind], ); diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index d4e8150f..4408b72c 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -144,6 +144,8 @@ export interface NotifySessionRef { 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; } diff --git a/src/state/store.tsx b/src/state/store.tsx index ee241448..cad71804 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -103,6 +103,8 @@ 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; } @@ -1466,13 +1468,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( @@ -1524,6 +1527,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { // for a different cached thread while another issue is open. threadId: info.thread_id, nativeId: info.native_id, + eventDriven: true, }, }; }); @@ -1542,42 +1546,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, - workspaceId: slot.workspace_id ?? undefined, + 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 @@ -1587,16 +1604,24 @@ 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 hydratePendingEventRef = useRef(false); const liveWorkersHydratedRef = useRef(false); - const hydrateLiveWorkers = useCallback(async () => { + const hydrateLiveWorkers = useCallback(async (eventDriven = false) => { if (hydratingRef.current) { hydratePendingRef.current = true; + hydratePendingEventRef.current ||= eventDriven; return; } hydratingRef.current = true; try { + let requestedEventDriven = eventDriven; do { hydratePendingRef.current = false; + const passEventDriven = + requestedEventDriven || + hydratePendingEventRef.current || + liveWorkersHydratedRef.current; + hydratePendingEventRef.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 @@ -1616,7 +1641,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }), ); - for (const slot of slots) adoptWorker(slot); + 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) => { @@ -1631,6 +1656,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } return changed ? next : prev; }); + requestedEventDriven = false; } while (hydratePendingRef.current); liveWorkersHydratedRef.current = true; setNotificationHydration((current) => ({ ...current, liveWorkers: true })); @@ -1657,7 +1683,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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; @@ -2054,6 +2080,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { [sid]: { ...m[sid], status: SESSION_STATUS[p.state], + eventDriven: true, }, } : m, From fa8b44c98ab3c82c90d880b13f4a024102623a14 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 18:07:52 +0800 Subject: [PATCH 25/35] fix(notify): finish workspace hydration guards --- src/lib/notifications.ts | 84 +++++++++++++++++++++++---- src/state/store.tsx | 121 ++++++++++++++++++++++++++++----------- 2 files changed, 163 insertions(+), 42 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index c5d5ec94..84452cb6 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -244,6 +244,7 @@ export async function handleNotifyOpen( activeWorkspaceId: number | null; workspaceLoadSeq: number; workspaceLoading?: boolean; + workspaceRestoring?: boolean; workspaceLoadReady?: boolean; needsHydrated?: boolean; }, @@ -257,10 +258,10 @@ export async function handleNotifyOpen( 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. Capture the sequence before starting either - // load: the direct call plus the [activeWorkspaceId] effect each bump it. + // 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 + 2, + expectedLoadSeq: deps.workspaceLoadSeq + 1, }); await deps.selectWorkspace(workspaceIntent.workspaceId); return; @@ -289,6 +290,10 @@ export async function handleNotifyOpen( } return; } + if (deps.workspaceRestoring) { + stashPendingNav(payload); + return; + } const needsIntent = intents.some((intent) => intent.type === "needs"); if (needsIntent && deps.needsHydrated === false) { stashPendingNav(payload); @@ -351,6 +356,7 @@ export function useSystemNotifications() { threadWorkspaceById, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, selectWorkspace, goToDirectionRef, @@ -372,6 +378,8 @@ export function useSystemNotifications() { quota: false, }); const liveWorkersReadyPrevious = useRef(false); + const asksReadyPrevious = useRef(false); + const workspaceNeedsReadyPrevious = useRef(false); const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -456,6 +464,7 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, needsHydrated, }); @@ -466,6 +475,7 @@ export function useSystemNotifications() { // 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); }, @@ -481,6 +491,7 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, needsHydrated, }; @@ -493,6 +504,7 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, needsHydrated, ]); @@ -612,6 +624,10 @@ export function useSystemNotifications() { 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. @@ -673,6 +689,7 @@ export function useSystemNotifications() { activeWorkspaceId, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, needsHydrated, goToDirectionRef, @@ -700,6 +717,9 @@ export function useSystemNotifications() { useEffect(() => { const sourceWorkspaceMatches = notificationHydration.workspaceId === activeWorkspaceId; + const asksReady = sourceWorkspaceMatches && notificationHydration.asks; + const workspaceNeedsReady = + sourceWorkspaceMatches && notificationHydration.workspaceNeeds; const liveWorkersReady = sourceWorkspaceMatches && notificationHydration.liveWorkers; const sessionRefs: Record< @@ -738,7 +758,7 @@ export function useSystemNotifications() { activeWorkspaceId, ); const sourceReady: Record = { - needs: sourceWorkspaceMatches && notificationHydration.needs, + needs: asksReady || workspaceNeedsReady, review: sourceWorkspaceMatches && notificationHydration.overview, // Lead-turn pushes and locally observed sessions are authoritative even // while the adopted-worker snapshot is retrying. @@ -761,27 +781,59 @@ export function useSystemNotifications() { } const sameWorkspace = baselineWs.current === activeWorkspaceId && prev.current != null; + const needsSourceBecameUnready = + (asksReadyPrevious.current && !asksReady) || + (workspaceNeedsReadyPrevious.current && !workspaceNeedsReady); const liveWorkersBecameReady = liveWorkersReady && !liveWorkersReadyPrevious.current; - const sourceBecameUnready = NOTIFY_CATEGORIES.some( - (kind) => sourceReadyPrevious.current[kind] && !sourceReady[kind], - ); + const sourceBecameUnready = + needsSourceBecameUnready || + NOTIFY_CATEGORIES.some( + (kind) => sourceReadyPrevious.current[kind] && !sourceReady[kind], + ); if (!sameWorkspace || sourceBecameUnready) { baselineWs.current = activeWorkspaceId; prev.current = emptyNotifySnapshot(); baselineReady.current.clear(); 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)) { - base[kind] = new Map(next[kind]); + if (kind === "needs") { + base.needs = new Map( + [...next.needs].filter(([key]) => isNeedsKeyReady(key)), + ); + } else { + base[kind] = new Map(next[kind]); + } baselineReady.current.add(kind); } } + if (asksBecameReady) { + for (const [key, entry] of next.needs) { + if (key.startsWith("ask:")) base.needs.set(key, entry); + } + } + 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. @@ -797,10 +849,22 @@ export function useSystemNotifications() { // compared once the unrelated source also becomes authoritative. return; } - const events = diffForNotifications(base, next, notifyCategories); + const nextForDiff = { + ...next, + needs: new Map( + [...next.needs].filter(([key]) => isNeedsKeyReady(key)), + ), + }; + const events = diffForNotifications(base, nextForDiff, notifyCategories); // Advance every category before the foreground/quiet-hours gate, preserving // the existing behavior that suppressed events are not replayed later. - prev.current = next; + const baselineNeeds = new Map( + [...base.needs].filter(([key]) => !isNeedsKeyReady(key)), + ); + for (const [key, entry] of next.needs) { + if (isNeedsKeyReady(key)) baselineNeeds.set(key, entry); + } + prev.current = { ...next, needs: baselineNeeds }; if (workspaceLoading) return; if (!notifyEnabled || permissionState !== "granted") return; if ( diff --git a/src/state/store.tsx b/src/state/store.tsx index cad71804..b796c69c 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -75,6 +75,11 @@ 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; @@ -224,6 +229,8 @@ interface Store { 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; @@ -623,9 +630,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [workspaceRestoring, setWorkspaceRestoring] = useState(true); const [workspaceLoadReady, setWorkspaceLoadReady] = useState(false); const workspaceLoadCountRef = useRef(0); const workspaceSelectionGenerationRef = useRef(0); + const workspaceSelectionInFlightRef = useRef>>(new Map()); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -650,6 +659,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [sessions, setSessions] = useState>({}); const [notificationHydration, setNotificationHydration] = useState({ workspaceId: null, + asks: false, + workspaceNeeds: false, needs: false, overview: false, quota: false, @@ -984,25 +995,34 @@ 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); + 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) => { + const selectWorkspace = useCallback((id: number): Promise => { + const inFlight = workspaceSelectionInFlightRef.current.get(id); + if (inFlight) return inFlight; + const load = (async () => { const selectionGeneration = ++workspaceSelectionGenerationRef.current; workspaceLoadCountRef.current += 1; setActiveWorkspaceId(id); + setWorkspaceRestoring(false); setWorkspaceLoading(true); setWorkspaceLoadReady(false); // Do not render the previous workspace's actionable rows under the new @@ -1012,6 +1032,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setNotificationHydration((current) => ({ ...current, workspaceId: id, + workspaceNeeds: false, needs: false, overview: false, })); @@ -1059,6 +1080,22 @@ export function StoreProvider({ children }: { children: ReactNode }) { workspaceLoadCountRef.current -= 1; if (workspaceLoadCountRef.current === 0) setWorkspaceLoading(false); } + })(); + const tracked = load.then( + () => { + if (workspaceSelectionInFlightRef.current.get(id) === tracked) { + workspaceSelectionInFlightRef.current.delete(id); + } + }, + (error) => { + if (workspaceSelectionInFlightRef.current.get(id) === tracked) { + workspaceSelectionInFlightRef.current.delete(id); + } + throw error; + }, + ); + workspaceSelectionInFlightRef.current.set(id, tracked); + return tracked; }, [rememberThreads]); const loadThreadChildren = useCallback(async (threadId: number) => { @@ -2436,13 +2473,18 @@ export function StoreProvider({ children }: { children: ReactNode }) { const requestId = ++needsRefreshSeqRef.current; const isLatestRequest = () => requestId === needsRefreshSeqRef.current; // Permission Asks are global (not workspace-scoped); always refresh them. - let notificationNeedsReady = true; try { const nextAsks = await api.pendingAsks(); - if (isLatestRequest()) setAsks(nextAsks); + if (isLatestRequest()) { + setAsks(nextAsks); + setNotificationHydration((current) => ({ + ...current, + asks: true, + needs: current.workspaceNeeds, + })); + } } catch (e) { /* server may not be ready */ - notificationNeedsReady = false; console.error(e); } // Standing grants are global too; refresh so "inherited access" markers stay @@ -2467,6 +2509,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { if (isLatestRequest() && activeWorkspaceIdRef.current == null) { setNeeds([]); setWriteTriggers([]); + setNotificationHydration((current) => ({ + ...current, + workspaceNeeds: true, + needs: current.asks, + })); } return; } @@ -2478,10 +2525,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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 */ - notificationNeedsReady = false; console.error(e); } // per-workspace counts so the switcher can flag OTHER workspaces. @@ -2492,12 +2546,6 @@ export function StoreProvider({ children }: { children: ReactNode }) { /* ignore */ console.error(e); } - if (isLatestRequest() && notificationNeedsReady && activeWorkspaceIdRef.current === workspaceId) { - setNotificationHydration((current) => { - if (current.workspaceId !== workspaceId) return current; - return { ...current, needs: true }; - }); - } }, []); const scheduleTrailingNeedsRefresh = useCallback(() => { @@ -3114,16 +3162,23 @@ export function StoreProvider({ children }: { children: ReactNode }) { const directionId = Number(dir); const all = Object.values(sessions); let live = - opts?.sessionId != null - ? all.find((s) => s.info.session_id === opts.sessionId) - : undefined; + 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) => + 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); + live = all.find( + (s) => s.directionId === directionId && s.status !== "exited", + ); } if (live) { setActiveThreadId(thread); @@ -3144,9 +3199,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 — @@ -3355,6 +3411,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { threadWorkspaceById, workspaceLoadSeq, workspaceLoading, + workspaceRestoring, workspaceLoadReady, directionsByThread, worktreesByDirection, From c2502e5d462df9ca845362edd2aa260b5dca5905 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 18:26:37 +0800 Subject: [PATCH 26/35] fix(notify): serialize hydration and ownership --- src/lib/notifications.ts | 22 +++-- src/state/store.tsx | 207 ++++++++++++++++++++++++++++----------- 2 files changed, 164 insertions(+), 65 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 84452cb6..05dc613d 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -468,6 +468,7 @@ export function useSystemNotifications() { workspaceLoadReady, needsHydrated, }); + const navigationTailRef = useRef>(Promise.resolve()); const navigationSelectWorkspace = useCallback( async (id: number) => { // React's passive effects can lag a notification event by one tick. Keep @@ -514,13 +515,12 @@ export function useSystemNotifications() { let cancelled = false; let takenPending: OsNotifyOpenEvent | null = null; const openInFlight = new Map>(); - let openTail: Promise = Promise.resolve(); const handleOpen = (payload: OsNotifyOpenEvent): Promise => { const key = notifyOpenKey(payload); const existing = openInFlight.get(key); if (existing) return existing; - const task = openTail.then(async () => { + const task = navigationTailRef.current.then(async () => { await handleNotifyOpen(payload, navDepsRef.current); // Native delivery retains the payload until the frontend confirms it was // handled. This also prevents a live event from being drained again by @@ -531,7 +531,7 @@ export function useSystemNotifications() { /* pure-vite */ } }); - openTail = task.catch(() => undefined); + navigationTailRef.current = task.catch(() => undefined); openInFlight.set(key, task); void task.then( () => { @@ -664,12 +664,16 @@ export function useSystemNotifications() { return; } pendingApplyingKey.current = applicationKey; - void applyNotifyIntents(pending, { - goToDirectionRef, - openNeeds, - openSettings, - openCurator, - }).then( + 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; diff --git a/src/state/store.tsx b/src/state/store.tsx index b796c69c..c3b3f636 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -634,7 +634,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [workspaceLoadReady, setWorkspaceLoadReady] = useState(false); const workspaceLoadCountRef = useRef(0); const workspaceSelectionGenerationRef = useRef(0); - const workspaceSelectionInFlightRef = useRef>>(new Map()); + const workspaceSelectionInFlightRef = useRef< + Map }> + >(new Map()); const rememberThreads = useCallback((list: Thread[]) => { setThreads(list); setThreadWorkspaceById((prev) => { @@ -733,6 +735,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { // 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 overviewTrailingTimerRef = useRef | null>(null); + const refreshOverviewRef = useRef<() => Promise>(() => Promise.resolve()); // Thread-bus drawer + proposal-review state. const [showBus, setShowBus] = useState(false); const [reviewingProposal, setReviewingProposal] = useState(false); @@ -999,6 +1005,31 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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; + }); setActiveWorkspaceId((cur) => { // Keep the live selection as long as it still exists. if (cur != null && ws.some((w) => w.id === cur)) return cur; @@ -1017,7 +1048,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { const selectWorkspace = useCallback((id: number): Promise => { const inFlight = workspaceSelectionInFlightRef.current.get(id); - if (inFlight) return inFlight; + if (inFlight && inFlight.generation === workspaceSelectionGenerationRef.current) { + return inFlight.promise; + } const load = (async () => { const selectionGeneration = ++workspaceSelectionGenerationRef.current; workspaceLoadCountRef.current += 1; @@ -1083,18 +1116,21 @@ export function StoreProvider({ children }: { children: ReactNode }) { })(); const tracked = load.then( () => { - if (workspaceSelectionInFlightRef.current.get(id) === tracked) { + if (workspaceSelectionInFlightRef.current.get(id)?.promise === tracked) { workspaceSelectionInFlightRef.current.delete(id); } }, (error) => { - if (workspaceSelectionInFlightRef.current.get(id) === tracked) { + if (workspaceSelectionInFlightRef.current.get(id)?.promise === tracked) { workspaceSelectionInFlightRef.current.delete(id); } throw error; }, ); - workspaceSelectionInFlightRef.current.set(id, tracked); + workspaceSelectionInFlightRef.current.set(id, { + generation: workspaceSelectionGenerationRef.current, + promise: tracked, + }); return tracked; }, [rememberThreads]); @@ -1130,28 +1166,67 @@ export function StoreProvider({ children }: { children: ReactNode }) { [loadThreadChildren], ); - const refreshOverview = useCallback(async () => { - if (activeWorkspaceId == null) { + const refreshOverviewOnce = useCallback(async () => { + const workspaceId = activeWorkspaceIdRef.current; + if (workspaceId == null) { overviewReqRef.current += 1; setOverview([]); return; } const reqId = ++overviewReqRef.current; try { - const data = await api.workspaceOverview(activeWorkspaceId); - // A newer refreshOverview() was issued while this one was in flight — + const data = await api.workspaceOverview(workspaceId); + // 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; + if ( + reqId !== overviewReqRef.current || + activeWorkspaceIdRef.current !== workspaceId + ) { + return; + } setOverview(data); setNotificationHydration((current) => { - if (current.workspaceId !== activeWorkspaceId) return current; + if (current.workspaceId !== workspaceId) return current; return { ...current, overview: true }; }); } catch (e) { /* ignore */ console.error(e); } - }, [activeWorkspaceId]); + }, []); + + const scheduleTrailingOverviewRefresh = useCallback(() => { + if (overviewTrailingTimerRef.current != null) return; + overviewTrailingTimerRef.current = setTimeout(() => { + overviewTrailingTimerRef.current = null; + if (overviewInFlightRef.current != null) return; + void refreshOverviewRef.current().catch((error) => console.error(error)); + }, 0); + }, []); + + const refreshOverview = useCallback(async () => { + const inFlight = overviewInFlightRef.current; + if (inFlight) { + // Let the current waiter resolve with the current pass; a slow poll gets + // one independent trailing refresh instead of an unbounded wait chain. + overviewPendingRef.current = true; + await inFlight; + return; + } + const current = refreshOverviewOnce(); + overviewInFlightRef.current = current; + try { + await current; + } finally { + if (overviewInFlightRef.current !== current) return; + overviewInFlightRef.current = null; + if (overviewPendingRef.current) { + overviewPendingRef.current = false; + scheduleTrailingOverviewRefresh(); + } + } + }, [refreshOverviewOnce, scheduleTrailingOverviewRefresh]); + refreshOverviewRef.current = refreshOverview; const backToWorkspace = useCallback(() => { setActiveThreadId(null); @@ -1642,7 +1717,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { const hydratingRef = useRef(false); const hydratePendingRef = useRef(false); 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; @@ -1650,51 +1737,46 @@ export function StoreProvider({ children }: { children: ReactNode }) { return; } hydratingRef.current = true; + const passEventDriven = + eventDriven || + hydratePendingEventRef.current || + liveWorkersHydratedRef.current; + hydratePendingEventRef.current = false; try { - let requestedEventDriven = eventDriven; - do { - hydratePendingRef.current = false; - const passEventDriven = - requestedEventDriven || - hydratePendingEventRef.current || - liveWorkersHydratedRef.current; - hydratePendingEventRef.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, 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; - } + 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); } - return changed ? next : prev; - }); - requestedEventDriven = false; - } while (hydratePendingRef.current); + }), + ); + 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) { @@ -1702,8 +1784,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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 @@ -1733,6 +1820,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { cancelled = true; un?.(); clearInterval(retryId); + if (hydrateTrailingTimerRef.current != null) { + clearTimeout(hydrateTrailingTimerRef.current); + hydrateTrailingTimerRef.current = null; + } }; }, [hydrateLiveWorkers]); @@ -3247,6 +3338,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { clearTimeout(needsRefreshTrailingTimerRef.current); needsRefreshTrailingTimerRef.current = null; } + if (overviewTrailingTimerRef.current != null) { + clearTimeout(overviewTrailingTimerRef.current); + overviewTrailingTimerRef.current = null; + } }; }, []); From 99e845d2a301a140bd169d5a002bf735b48abea2 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 18:51:46 +0800 Subject: [PATCH 27/35] fix(notifications): close latest review follow-ups --- src/lib/notifications.ts | 35 ++++++++--- src/state/store.tsx | 123 +++++++++++++++++++-------------------- 2 files changed, 88 insertions(+), 70 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 05dc613d..ee670263 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -48,14 +48,30 @@ export async function notifyPermission(): Promise { } /** Resolve to a settled state, asking the OS only from "prompt". */ -export async function ensureNotifyPermission(): Promise { - const p = await notifyPermission(); - if (p !== "prompt") return p; - try { - return asNotifyPermission(await api.osNotifyRequestPermission()); - } catch { - return "denied"; - } +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; @@ -677,6 +693,9 @@ export function useSystemNotifications() { () => { pendingApplyingKey.current = null; pendingAppliedKey.current = applicationKey; + void api.osNotifyAckOpen(pending).catch(() => { + /* pure-vite / older backend */ + }); }, () => { pendingApplyingKey.current = null; diff --git a/src/state/store.tsx b/src/state/store.tsx index c3b3f636..0bad6565 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -632,7 +632,6 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [workspaceLoading, setWorkspaceLoading] = useState(false); const [workspaceRestoring, setWorkspaceRestoring] = useState(true); const [workspaceLoadReady, setWorkspaceLoadReady] = useState(false); - const workspaceLoadCountRef = useRef(0); const workspaceSelectionGenerationRef = useRef(0); const workspaceSelectionInFlightRef = useRef< Map }> @@ -1052,67 +1051,67 @@ export function StoreProvider({ children }: { children: ReactNode }) { return inFlight.promise; } const load = (async () => { - const selectionGeneration = ++workspaceSelectionGenerationRef.current; - workspaceLoadCountRef.current += 1; - 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); - workspaceLoadCountRef.current -= 1; - if (workspaceLoadCountRef.current === 0) setWorkspaceLoading(false); - } + 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( () => { From ec8489020092cd959bedba73aa0dc42e3297b612 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 19:10:00 +0800 Subject: [PATCH 28/35] fix(notifications): preserve cross-workspace event state --- src/lib/notifications.ts | 115 ++++++++++++++++++++++++++++++--------- src/state/store.tsx | 28 ++++++++++ 2 files changed, 118 insertions(+), 25 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index ee670263..f2311330 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -264,7 +264,7 @@ export async function handleNotifyOpen( workspaceLoadReady?: boolean; needsHydrated?: boolean; }, -): Promise { +): Promise { await focusMainWindow(); const intents = planNotifyOpen(payload); const workspaceIntent = intents.find((i) => i.type === "workspace"); @@ -280,7 +280,7 @@ export async function handleNotifyOpen( expectedLoadSeq: deps.workspaceLoadSeq + 1, }); await deps.selectWorkspace(workspaceIntent.workspaceId); - return; + 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 @@ -289,7 +289,7 @@ export async function handleNotifyOpen( stashPendingNav(payload, { expectedLoadSeq: deps.workspaceLoadSeq + 1, }); - return; + return false; } } if ( @@ -304,25 +304,26 @@ export async function handleNotifyOpen( } catch { /* Keep the deferred route; a later successful load can apply it. */ } - return; + return false; } if (deps.workspaceRestoring) { stashPendingNav(payload); - return; + return false; } const needsIntent = intents.some((intent) => intent.type === "needs"); if (needsIntent && deps.needsHydrated === false) { stashPendingNav(payload); - return; + 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; + return false; } await applyNotifyIntents(payload, deps); + return true; } async function sendOsNotification( @@ -370,6 +371,7 @@ export function useSystemNotifications() { activeWorkspaceId, needsByWorkspace, threadWorkspaceById, + threadKindById, workspaceLoadSeq, workspaceLoading, workspaceRestoring, @@ -396,6 +398,11 @@ export function useSystemNotifications() { const liveWorkersReadyPrevious = useRef(false); const asksReadyPrevious = useRef(false); const workspaceNeedsReadyPrevious = useRef(false); + const globalBaselineReady = useRef({ + asks: false, + stalled: false, + quota: false, + }); const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -416,7 +423,7 @@ export function useSystemNotifications() { m[id] = { title: prevMeta?.title ?? `#${id}`, workspaceId: ws, - kind: prevMeta?.kind, + kind: threadKindById[id] ?? prevMeta?.kind, }; } for (const th of threads) { @@ -427,7 +434,7 @@ export function useSystemNotifications() { }; } threadsById.current = m; - }, [threads, threadWorkspaceById]); + }, [threads, threadWorkspaceById, threadKindById]); // OS permission, settled once per enable. useEffect(() => { @@ -530,14 +537,15 @@ export function useSystemNotifications() { let unlisten: (() => void) | undefined; let cancelled = false; let takenPending: OsNotifyOpenEvent | null = null; - const openInFlight = new Map>(); + const openInFlight = new Map>(); - const handleOpen = (payload: OsNotifyOpenEvent): Promise => { + const handleOpen = (payload: OsNotifyOpenEvent): Promise => { const key = notifyOpenKey(payload); const existing = openInFlight.get(key); if (existing) return existing; const task = navigationTailRef.current.then(async () => { - await handleNotifyOpen(payload, navDepsRef.current); + 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. @@ -546,8 +554,12 @@ export function useSystemNotifications() { } catch { /* pure-vite */ } + return true; }); - navigationTailRef.current = task.catch(() => undefined); + navigationTailRef.current = task.then( + () => undefined, + () => undefined, + ); openInFlight.set(key, task); void task.then( () => { @@ -580,8 +592,15 @@ export function useSystemNotifications() { takenPending = null; return; } - await handleOpen(pending); - takenPending = null; + 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 { if (takenPending) { try { @@ -805,19 +824,39 @@ export function useSystemNotifications() { const sameWorkspace = baselineWs.current === activeWorkspaceId && prev.current != null; const needsSourceBecameUnready = - (asksReadyPrevious.current && !asksReady) || - (workspaceNeedsReadyPrevious.current && !workspaceNeedsReady); + notifyCategories.needs && + ((asksReadyPrevious.current && !asksReady) || + (workspaceNeedsReadyPrevious.current && !workspaceNeedsReady)); const liveWorkersBecameReady = liveWorkersReady && !liveWorkersReadyPrevious.current; const sourceBecameUnready = needsSourceBecameUnready || NOTIFY_CATEGORIES.some( - (kind) => sourceReadyPrevious.current[kind] && !sourceReady[kind], + (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.quota) { + prev.current.quota = new Map(previous.quota); + } + } baselineReady.current.clear(); + globalBaselineReady.current = preserveGlobal; liveWorkersReadyPrevious.current = false; asksReadyPrevious.current = false; workspaceNeedsReadyPrevious.current = false; @@ -838,19 +877,42 @@ export function useSystemNotifications() { for (const kind of NOTIFY_CATEGORIES) { if (sourceReady[kind] && !baselineReady.current.has(kind)) { if (kind === "needs") { - base.needs = new Map( - [...next.needs].filter(([key]) => isNeedsKeyReady(key)), - ); + 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 === "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 === "quota") { + globalBaselineReady.current.quota = true; + } } baselineReady.current.add(kind); } } - if (asksBecameReady) { + 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) { @@ -864,10 +926,10 @@ export function useSystemNotifications() { if (!eventStalledKeys.has(key)) base.stalled.set(key, entry); } } - const allSourcesHydrated = NOTIFY_CATEGORIES.every( - (kind) => sourceReady[kind], + const allEnabledSourcesHydrated = NOTIFY_CATEGORIES.every( + (kind) => !notifyCategories[kind] || sourceReady[kind], ); - if (!allSourcesHydrated) { + if (!allEnabledSourcesHydrated) { // Keep ready categories' old baselines; newly arrived entries will be // compared once the unrelated source also becomes authoritative. return; @@ -935,5 +997,8 @@ export function useSystemNotifications() { windowFocused, t, permissionState, + threads, + threadWorkspaceById, + threadKindById, ]); } diff --git a/src/state/store.tsx b/src/state/store.tsx index 0bad6565..a05a271d 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -225,6 +225,8 @@ interface Store { 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. */ @@ -628,6 +630,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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); @@ -649,6 +652,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { } 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>({}); @@ -1029,6 +1043,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { } 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; @@ -3503,6 +3530,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { repos, threads, threadWorkspaceById, + threadKindById, workspaceLoadSeq, workspaceLoading, workspaceRestoring, From ff587ac503f843e120f58280cfe0de2c6f52c8c4 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 19:24:43 +0800 Subject: [PATCH 29/35] fix(notifications): cover workspace-wide review refresh --- src/lib/notifications.ts | 24 +++++++++++---- src/state/store.tsx | 64 +++++++++++++++++++++++----------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index f2311330..c07bd88b 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -359,7 +359,7 @@ export function useSystemNotifications() { needs, asks, writeTriggers, - overview, + notificationOverview, sessions, leadTurn, processQuota, @@ -400,6 +400,7 @@ export function useSystemNotifications() { const workspaceNeedsReadyPrevious = useRef(false); const globalBaselineReady = useRef({ asks: false, + review: false, stalled: false, quota: false, }); @@ -792,20 +793,24 @@ export function useSystemNotifications() { needs, asks, writeTriggers, - overview, + notificationOverview, sessionRefs, leadTurn, processQuota, threadsById.current, activeWorkspaceId, ); + // 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: sourceWorkspaceMatches && notificationHydration.overview, + review: notificationHydration.overview, // Lead-turn pushes and locally observed sessions are authoritative even // while the adopted-worker snapshot is retrying. stalled: sourceWorkspaceMatches, - quota: sourceWorkspaceMatches && notificationHydration.quota, + quota: notificationHydration.quota, }; const eventStalledKeys = new Set(); for (const s of Object.values(sessionRefs)) { @@ -851,6 +856,9 @@ export function useSystemNotifications() { 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); } @@ -890,6 +898,8 @@ export function useSystemNotifications() { 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 ) { @@ -901,6 +911,9 @@ export function useSystemNotifications() { if (kind === "stalled") { globalBaselineReady.current.stalled = true; } + if (kind === "review") { + globalBaselineReady.current.review = true; + } if (kind === "quota") { globalBaselineReady.current.quota = true; } @@ -950,7 +963,6 @@ export function useSystemNotifications() { if (isNeedsKeyReady(key)) baselineNeeds.set(key, entry); } prev.current = { ...next, needs: baselineNeeds }; - if (workspaceLoading) return; if (!notifyEnabled || permissionState !== "granted") return; if ( isAppInForeground({ @@ -984,7 +996,7 @@ export function useSystemNotifications() { needs, asks, writeTriggers, - overview, + notificationOverview, sessions, leadTurn, processQuota, diff --git a/src/state/store.tsx b/src/state/store.tsx index a05a271d..5ad04a14 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -482,6 +482,8 @@ interface Store { /** Workspace board: per-thread roll-ups for the portfolio view. */ overview: ThreadOverview[]; + /** All-workspace roll-ups used by background review notifications. */ + notificationOverview: ThreadOverview[]; refreshOverview: () => Promise; selectWorkspace: (id: number) => Promise; @@ -627,6 +629,8 @@ 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>({}); @@ -743,6 +747,7 @@ 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 — @@ -750,7 +755,6 @@ export function StoreProvider({ children }: { children: ReactNode }) { const overviewReqRef = useRef(0); const overviewInFlightRef = useRef | null>(null); const overviewPendingRef = useRef(false); - const overviewTrailingTimerRef = useRef | null>(null); const refreshOverviewRef = useRef<() => Promise>(() => Promise.resolve()); // Thread-bus drawer + proposal-review state. const [showBus, setShowBus] = useState(false); @@ -1193,26 +1197,37 @@ export function StoreProvider({ children }: { children: ReactNode }) { ); const refreshOverviewOnce = useCallback(async () => { - const workspaceId = activeWorkspaceIdRef.current; - if (workspaceId == null) { + 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(workspaceId); + const entries = await Promise.all( + [...workspaceIds].map(async (workspaceId) => [ + workspaceId, + await api.workspaceOverview(workspaceId), + ] as const), + ); // 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 || - activeWorkspaceIdRef.current !== workspaceId + activeWorkspaceIdRef.current !== activeId ) { return; } - setOverview(data); + const allOverview = entries.flatMap(([, data]) => data); + const activeOverview = entries.find(([workspaceId]) => workspaceId === activeId)?.[1] ?? []; + setOverview(activeOverview); + setNotificationOverview(allOverview); setNotificationHydration((current) => { - if (current.workspaceId !== workspaceId) return current; + if (current.workspaceId !== activeId) return current; return { ...current, overview: true }; }); } catch (e) { @@ -1221,24 +1236,25 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, []); - const scheduleTrailingOverviewRefresh = useCallback(() => { - if (overviewTrailingTimerRef.current != null) return; - overviewTrailingTimerRef.current = setTimeout(() => { - overviewTrailingTimerRef.current = null; - if (overviewInFlightRef.current != null) return; - void refreshOverviewRef.current().catch((error) => console.error(error)); - }, 0); - }, []); - const refreshOverview = useCallback(async () => { const inFlight = overviewInFlightRef.current; if (inFlight) { - // Let the current waiter resolve with the current pass; a slow poll gets - // one independent trailing refresh instead of an unbounded wait chain. + // 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(); + } return; } + overviewPendingRef.current = false; const current = refreshOverviewOnce(); overviewInFlightRef.current = current; try { @@ -1246,12 +1262,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { } finally { if (overviewInFlightRef.current !== current) return; overviewInFlightRef.current = null; - if (overviewPendingRef.current) { - overviewPendingRef.current = false; - scheduleTrailingOverviewRefresh(); - } } - }, [refreshOverviewOnce, scheduleTrailingOverviewRefresh]); + }, [refreshOverviewOnce]); refreshOverviewRef.current = refreshOverview; const backToWorkspace = useCallback(() => { @@ -1375,6 +1387,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setSelectedRepoId(null); setProposal(null); setOverview([]); + setNotificationOverview([]); }, [selectWorkspace], ); @@ -3364,10 +3377,6 @@ export function StoreProvider({ children }: { children: ReactNode }) { clearTimeout(needsRefreshTrailingTimerRef.current); needsRefreshTrailingTimerRef.current = null; } - if (overviewTrailingTimerRef.current != null) { - clearTimeout(overviewTrailingTimerRef.current); - overviewTrailingTimerRef.current = null; - } }; }, []); @@ -3648,6 +3657,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setProposalDirectionBase, approvePlanCard, overview, + notificationOverview, refreshOverview, selectWorkspace, refreshWorkspaces, From 3b9809d2ed097ae71901a32a72436211dc9b2f27 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 19:35:42 +0800 Subject: [PATCH 30/35] fix(notifications): harden workspace overview polling --- src/lib/notificationsCore.ts | 10 ++++++-- src/state/store.tsx | 49 ++++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts index 4408b72c..4d99e956 100644 --- a/src/lib/notificationsCore.ts +++ b/src/lib/notificationsCore.ts @@ -22,6 +22,9 @@ export const NOTIFY_CATEGORIES: readonly NotifyCategory[] = [ 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, @@ -193,7 +196,7 @@ export function snapshotOf( needs: NeedItem[], asks: PermissionAsk[], triggers: WriteTrigger[], - overview: ThreadOverview[], + overview: NotificationOverview[], sessions: Record, leadTurn: Record, processQuota: ProcessQuotaStatus | null, @@ -259,7 +262,10 @@ export function snapshotOf( if (s !== "review") return; const directionId = o.direction_ids[i]; const ws = - threadsById[o.thread_id]?.workspaceId ?? workspaceId ?? undefined; + o.workspace_id ?? + threadsById[o.thread_id]?.workspaceId ?? + workspaceId ?? + undefined; r.set(`rev:${directionId}`, { sample: o.title, route: { diff --git a/src/state/store.tsx b/src/state/store.tsx index 5ad04a14..c04b0afb 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -33,6 +33,7 @@ import { serializeQuietHours, type NotifyCategory, type NotifyCategoryFlags, + type NotificationOverview, type QuietHours, } from "../lib/notificationsCore"; import { fillMetaHoles, mergeSnapshot, metaFromInit, metaFromSnapshot, metaFromUsage } from "../session/sessionMeta"; @@ -483,8 +484,8 @@ interface Store { /** Workspace board: per-thread roll-ups for the portfolio view. */ overview: ThreadOverview[]; /** All-workspace roll-ups used by background review notifications. */ - notificationOverview: ThreadOverview[]; - refreshOverview: () => Promise; + notificationOverview: NotificationOverview[]; + refreshOverview: (force?: boolean) => Promise; selectWorkspace: (id: number) => Promise; refreshWorkspaces: () => Promise; @@ -747,7 +748,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } | null>(null); const [proposal, setProposal] = useState(null); const [overview, setOverview] = useState([]); - const [notificationOverview, setNotificationOverview] = 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 — @@ -755,7 +756,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { const overviewReqRef = useRef(0); const overviewInFlightRef = useRef | null>(null); const overviewPendingRef = useRef(false); - const refreshOverviewRef = useRef<() => Promise>(() => Promise.resolve()); + const refreshOverviewRef = useRef<(force?: boolean) => Promise>( + () => Promise.resolve(), + ); // Thread-bus drawer + proposal-review state. const [showBus, setShowBus] = useState(false); const [reviewingProposal, setReviewingProposal] = useState(false); @@ -1209,10 +1212,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { const reqId = ++overviewReqRef.current; try { const entries = await Promise.all( - [...workspaceIds].map(async (workspaceId) => [ - workspaceId, - await api.workspaceOverview(workspaceId), - ] as const), + [...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. @@ -1222,13 +1232,16 @@ export function StoreProvider({ children }: { children: ReactNode }) { ) { return; } - const allOverview = entries.flatMap(([, data]) => data); - const activeOverview = entries.find(([workspaceId]) => workspaceId === activeId)?.[1] ?? []; - setOverview(activeOverview); + 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: true }; + return { ...current, overview: allWorkspacesSucceeded }; }); } catch (e) { /* ignore */ @@ -1236,9 +1249,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, []); - const refreshOverview = useCallback(async () => { + 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; @@ -1250,7 +1267,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } if (overviewPendingRef.current) { overviewPendingRef.current = false; - await refreshOverviewRef.current(); + await refreshOverviewRef.current(true); } return; } @@ -1542,7 +1559,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { // views are already consistent. See issue #106. const [threadList] = await Promise.all([ api.listThreads(activeWorkspaceId), - refreshOverview(), + refreshOverview(true), ]); rememberThreads(threadList); return t; @@ -2775,7 +2792,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], From 0b7ff036fb1ed54b2e04eaa6b842510a3be3fbc0 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 19:51:26 +0800 Subject: [PATCH 31/35] fix(notifications): isolate ready sources and stale navigation --- src/lib/notifications.ts | 37 ++++++++++++++++++++++++------------- src/state/store.tsx | 19 +++++++++++++++---- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index c07bd88b..14464f81 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -15,6 +15,7 @@ import { planNotifyOpen, snapshotOf, type NotifyCategory, + type NotifyCategoryFlags, type NotifyRoute, type NotifySnapshot, } from "./notificationsCore"; @@ -806,7 +807,7 @@ export function useSystemNotifications() { if (workspaceLoading) return; const sourceReady: Record = { needs: asksReady || workspaceNeedsReady, - review: notificationHydration.overview, + review: sourceWorkspaceMatches && notificationHydration.overview, // Lead-turn pushes and locally observed sessions are authoritative even // while the adopted-worker snapshot is retrying. stalled: sourceWorkspaceMatches, @@ -939,30 +940,40 @@ export function useSystemNotifications() { if (!eventStalledKeys.has(key)) base.stalled.set(key, entry); } } - const allEnabledSourcesHydrated = NOTIFY_CATEGORIES.every( - (kind) => !notifyCategories[kind] || sourceReady[kind], - ); - if (!allEnabledSourcesHydrated) { - // Keep ready categories' old baselines; newly arrived entries will be - // compared once the unrelated source also becomes authoritative. - return; - } const nextForDiff = { ...next, needs: new Map( [...next.needs].filter(([key]) => isNeedsKeyReady(key)), ), }; - const events = diffForNotifications(base, nextForDiff, notifyCategories); - // Advance every category before the foreground/quiet-hours gate, preserving - // the existing behavior that suppressed events are not replayed later. + // 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); } - prev.current = { ...next, needs: baselineNeeds }; + 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({ diff --git a/src/state/store.tsx b/src/state/store.tsx index c04b0afb..a3f88ca3 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -672,6 +672,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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); @@ -1182,6 +1186,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const selectThread = useCallback( async (threadId: number) => { + const selectionGeneration = ++threadSelectionGenerationRef.current; setActiveThreadId(threadId); setViewing(null); setShowNeeds(false); @@ -1190,9 +1195,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { setShowBus(false); setReviewingProposal(false); try { - setProposal(await api.getProposal(threadId)); + const nextProposal = await api.getProposal(threadId); + if (selectionGeneration !== threadSelectionGenerationRef.current) return; + setProposal(nextProposal); } catch (e) { - setProposal(null); + if (selectionGeneration === threadSelectionGenerationRef.current) { + setProposal(null); + } } await loadThreadChildren(threadId); }, @@ -3400,7 +3409,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { // 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) return; + if (activeWorkspaceId == null || !notifyEnabled || !notifyCategories.review) { + return; + } let alive = true; const tick = () => { if (alive) void refreshOverview(); @@ -3411,7 +3422,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { alive = false; clearInterval(h); }; - }, [activeWorkspaceId, refreshOverview]); + }, [activeWorkspaceId, notifyEnabled, notifyCategories.review, refreshOverview]); // Live-refresh the repo map when the curator calibrates an edge (or the auto From 3946632749272694b97b7c7e0939f5a36d987a77 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 20:08:16 +0800 Subject: [PATCH 32/35] fix(notifications): rebaseline muted review state --- src/lib/notifications.ts | 23 ++++++++++++++++++++++- src/nav/SettingsDialog.tsx | 4 +++- src/state/store.tsx | 16 +++++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 14464f81..c9e5a537 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -77,6 +77,12 @@ export function ensureNotifyPermission(): Promise { /** 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; let url: string | null = null; @@ -405,6 +411,9 @@ export function useSystemNotifications() { stalled: false, quota: false, }); + const reviewNotificationsEnabledPrevious = useRef( + notifyEnabled && notifyCategories.review, + ); const [permissionState, setPermissionState] = useState("prompt"); const [windowFocused, setWindowFocused] = useState(null); const lastBadge = useRef(null); @@ -805,9 +814,21 @@ export function useSystemNotifications() { // resetting. Global asks/stalls/quota/review transitions must survive until // the settled snapshot can be compared. if (workspaceLoading) return; + 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; + } const sourceReady: Record = { needs: asksReady || workspaceNeedsReady, - review: sourceWorkspaceMatches && notificationHydration.overview, + review: + reviewNotificationsEnabled && + sourceWorkspaceMatches && + notificationHydration.overview, // Lead-turn pushes and locally observed sessions are authoritative even // while the adopted-worker snapshot is retrying. stalled: sourceWorkspaceMatches, diff --git a/src/nav/SettingsDialog.tsx b/src/nav/SettingsDialog.tsx index ea6a1854..1de1e5ae 100644 --- a/src/nav/SettingsDialog.tsx +++ b/src/nav/SettingsDialog.tsx @@ -37,6 +37,7 @@ import { api } from "../lib/api"; import { cn } from "../lib/cn"; import { spawnableToolsOf } from "../lib/toolStatus.ts"; import { + canOpenSystemNotificationSettings, ensureNotifyPermission, formatQuietTime, NOTIFY_CATEGORIES, @@ -237,6 +238,7 @@ function GeneralSettings() { const [lang, setLangState] = useState(currentLang()); const spawnable = spawnableToolsOf(installedTools); + const canOpenNotificationSettings = canOpenSystemNotificationSettings(); // Per-tool command overrides ("aliases", e.g. claude → cc-claude). `draft` // holds in-progress edits; `saved` is what the backend persisted, so a Save @@ -403,7 +405,7 @@ function GeneralSettings() { onChange={onNotifyToggle} label={t("settings.notifications")} /> - {notifyEnabled && notifyPerm === "denied" && ( + {notifyEnabled && notifyPerm === "denied" && 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 @@ -405,15 +418,7 @@ function GeneralSettings() { onChange={onNotifyToggle} label={t("settings.notifications")} /> - {notifyEnabled && notifyPerm === "denied" && canOpenNotificationSettings && ( - - )} + {notifyEnabled && notifyPerm === "denied" && deniedNotificationHint} {notifyEnabled && (
{NOTIFY_CATEGORIES.map((kind) => ( diff --git a/src/state/store.tsx b/src/state/store.tsx index af18df6c..b9cd901d 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -831,6 +831,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [processQuota, setProcessQuota] = useState(null); const processQuotaRef = useRef(null); + const processQuotaObservedRef = useRef(null); const processQuotaHydratedRef = useRef(false); const applyProcessQuota = useCallback((next: ProcessQuotaStatus) => { const previous = processQuotaRef.current; @@ -848,6 +849,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { try { const next = await api.processQuotaStatus(); applyProcessQuota(next); + const observed = processQuotaObservedRef.current; + if (observed === null || shouldApplyProcessQuotaStatus(observed, next)) { + processQuotaObservedRef.current = next; + } processQuotaHydratedRef.current = true; setNotificationHydration((current) => ({ ...current, quota: true })); } catch (error) { @@ -857,11 +862,20 @@ 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 || + shouldApplyProcessQuotaStatus(previous, event.payload) + ) { + processQuotaObservedRef.current = event.payload; + } applyProcessQuota(event.payload); setNotificationHydration((current) => ({ ...current, quota: processQuotaHydratedRef.current, - quotaPushPending: true, + quotaPushPending: current.quotaPushPending || isNewTransition, })); }); void refreshProcessQuota(); From d636da6e3220ce0d5f8e67fee5253b3fa61fdf7f Mon Sep 17 00:00:00 2001 From: t Date: Fri, 31 Jul 2026 20:41:53 +0800 Subject: [PATCH 35/35] fix(notifications): retain first quota transition --- src/state/store.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/state/store.tsx b/src/state/store.tsx index b9cd901d..e9407e80 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -832,6 +832,7 @@ 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; @@ -848,13 +849,24 @@ export function StoreProvider({ children }: { children: ReactNode }) { const refreshProcessQuota = useCallback(async () => { try { 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 })); + 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); @@ -865,6 +877,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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)