fix(app-shell): one inbox feed for the bell and Home — already-read messages stop counting as "needs your attention" - #4327
Merged
Conversation
…essages stop counting as "needs your attention" (#4316) (#4225) `useHomeInbox` read `sys_inbox_message` alone and never joined `sys_notification_receipt`, where ADR-0030 resolved decision 2 puts read-state. Home's action centre therefore could not tell a read message from an unread one: it listed the five most recent unconditionally and badged them, so a user who had just read all nine in the bell came back to Home and found five of them still filed as work waiting — while the bell above, which did join the receipts, correctly showed zero (#4316). Both consumers now derive from one inbox feed in `sharedUserFeeds`: the bell's already-joined 20-row window, polled once at its own 10s cadence. Home takes the unread rows, newest first, capped at its limit. The duplicate read #4225 was filed for is gone, and the two surfaces cannot disagree about a row's read-state because there is no second read left to drift. The store gained the status dialect it lacked (`idle`/`loading`/`ready`/`error`, #4300's one ruling) so a failed read cannot reach a consumer as stale-but-ready data — the failure mode #4235 closed at the hook, closed again at the store, for every feed. The bell's hidden-tab throttle, visibility refetch and failure backoff moved into the store with the poll rather than being dropped. Rider: the three `isMissingResource` copies collapse onto one exported predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…s cached answer `useSharedFeed` handed back the store's snapshot even when the consumer had no key — no adapter, or no signed-in user. The value and the `ready` that came with it belong to the key that earned them, so a signed-out session could read the previous user's rows and be told they were an answer. It is the same distinction #4235 drew for the hook it replaced, whose `idle` branch fired exactly here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 11, 2026 15:44
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4316
Part of #4225
What was wrong
useHomeInboxreadsys_inbox_messageand nothing else — it never joinedsys_notification_receipt, where ADR-0030 (resolved decision 2) puts read-state:"Read-state lives in
sys_notification_receipt(per recipient×channel), not onsys_inbox_message". So Home's "Needs your attention" card could not tell a readmessage from an unread one. It listed the five most recent unconditionally and
badged them.
A user who opened the bell, read all nine messages and returned to Home still
found up to five of them filed as work waiting on them — while the bell two
hundred pixels above correctly showed zero, because its own poll did join the
receipts (#4316). One page load, two panels, opposite claims about the same rows.
That is also the fact that falsified #4225's own premise. #4225 was filed
observation-class — "Both consumers show correct data today. Nothing is stale,
nothing disagrees" — and #4316 measured that they did disagree.
The fix
The route #4225 sketched, and the one #4316 explicitly asks for instead of adding
a second read:
hooks/sharedUserFeeds.tsgains an inbox feed holding thebell's already-joined 20-row window, polled once at the bell's 10s cadence. Both
consumers derive from it — the bell lists the window and badges its unread
topics; Home takes the unread rows newest-first, capped at its own smaller limit.
Home's second query is gone, and the two surfaces cannot disagree about a row's
read-state because there is no second read left to drift from the first. The
bell's source is unchanged and sanctioned: the raw table read joined with
receipts, per #4230's disposition and #4235's ADR measurement.
Two supporting changes travel with it, both visible only when something fails:
failure into "keep the last value" and say nothing, which a consumer cannot
tell from a successful re-read returning the same thing — so routing Home's
read through it would have quietly undone The console HOME header renders a second, different inbox polling the wrong source —
sys_inbox_messageinstead of/api/v1/notifications, no badge, "You're all caught up" with 9 unread #4235'serrorstate.SharedFeednow reports
idle/loading/ready/errorfor every feed, the samefour words fix(console): an unloadable app list is UNKNOWN, not "no default app"; wire the Applications page's writes (#4233) #4300 ruled and The console HOME header renders a second, different inbox polling the wrong source —
sys_inbox_messageinstead of/api/v1/notifications, no badge, "You're all caught up" with 9 unread #4235 applied, rather than a second inbox-onlydialect. A missing object (404 /
OBJECT_NOT_FOUND) is still an answer; adenial still is not.
— hidden-tab throttle (60s), return-to-tab refetch, failure backoff (cap 2 min)
— and now applies to every shared feed. The store had none of the three; a
shared feed polling a hidden tab at its foreground rate would have been a
regression riding in on a de-duplication fix.
Rider: the three
isMissingResourcecopies (sharedUserFeeds,AppHeader,useHomeInbox) collapse onto one exported predicate. Semantics identical, andthe #4235 404-is-an-answer split is pinned on the survivor.
Mark-read now writes to a local optimistic overlay in
AppHeaderinstead ofmutating rows that are shared — one surface's optimistic flip must not become
another's fact before the server agrees.
Tests
pnpm --filter @object-ui/app-shell type-check(both tsc commands) clean; lint 0errors; 347 test files / 3319 tests green.
Both guard suites pass. #4284's bell tripwire suite
(
AppHeader.inboxVariant.test.tsx, 30 cases) is byte-identical untouched —it went green on the consolidated code with no edit at all.
#4315's suite (
HomeActionCenter.unansweredInbox.test.tsx) needed three edits,and none of them touch its subject. All seven of its status/error cases — denial
⇒ error, no user ⇒ loading, no adapter ⇒ unanswered, half-answer, empty ⇒ caught
up, 404 ⇒ caught up, approvals-only — pass unchanged against the real shared
store. What changed:
vi.mockofsharedUserFeedsreplaced the module wholesale, so the newuseSharedInboxFeedexport did not exist under it. Now partial viaimportOriginal, which keeps the inbox read real — the suite's statedoracle is "the adapter boundary is where a denial becomes a rejected promise,
and that is the hop under test", and that hop is preserved exactly.
$top: 5⇒$top: 20. The read is the bell's superset now; theminescopeand ordering this case actually pins are untouched.
toHaveLength(9)⇒(5), badge'9'⇒'5'. Five is what a realdeployment always showed. The cap used to travel as the read's own
$top: 5, which that suite's fake adapter ignores — it answers every querywith the full fixture — so the case measured nine only because nothing in the
test enforced the server's cut. The cap is now a client-side slice and is
enforced in the test exactly as the server enforced it in production.
New pins:
sharedInboxFeed.twoSurfaces.test.tsx(11 cases) mounts the realAppHeaderand the realHomeActionCenterin one tree over one fake adapterand asserts what BOTH show — including #4316's exact shape (nine messages all
read ⇒ bell badges nothing AND Home lists nothing), one unread among nine, mixed
read-state, and both surfaces reading the same badge number.
sharedUserFeeds.isMissingResource.test.ts(5 cases) pins both polarities of thededuplicated predicate.
Reverse verification (predictions written before running)
A — drop only the
!m.is_readfilter, keep the shared feed. Predicted: the#4316 block red, the one-read block green. Measured: exactly 4 red, all four
read-state cases; one-read block green; both guard suites green. This is why the
read-count pin cannot stand in for the read-state pin, and why both exist.
B — restore
origin/main'suseHomeInbox(undo the consolidation).Predicted: one-read block red, #4316 block red, #4284 still green. Measured:
10 red — and #4284's suite green, exactly the blind spot that made the new suite
necessary.
One honest exception: "issues ONE
sys_notification_receiptread" stayed greenunder B, because the old Home hook never read receipts at all, so the count was 1
either way. That pin is not load-bearing against the un-consolidated shape; it
guards the other regression — Home growing its own receipt read, which is the
naive #4316 fix that #4316 itself argues against.
Generated by Claude Code