Repository navigation
fix(app-shell): the inbox opens on the first tab with items (objectui#11698) - #11743
Merged
objectstack-fleet[bot] merged 3 commits intoOct 6, 2026
Merged
Conversation
…#11698) The popover's tab was a fixed 'notifications', so a bell reading "3" for three pending approvals opened on "You're all caught up". On each open it now picks the user's last-picked tab if that tab has items, else the first tab with items (Notifications, Approvals, Activity), else the pick, else Notifications. A picked tab is remembered in sessionStorage so the header bells of Home, an app and the other layouts share it for the tab session. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…reads (objectui#11698) The suite read the Notifications panel as whatever the bell opened on. The bell now opens on the user's last pick in the browser-tab session, and the first case's pick of Activity stays in sessionStorage, so the en-US control read the Activity panel twice. Select Notifications explicitly too. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…(objectui#11698) With every tab empty the popover reopens on the tab the user last selected, and on Notifications only when nothing was selected. The changeset said Notifications unconditionally. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-11698-inbox-opens-on-items
branch
October 6, 2026 19:27
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 #11698
Clause-②: no
What changed
@object-ui/app-shellInboxPopover: the bell's popover no longer always opens on Notifications. The tab was auseState('notifications')that nothing moved, while the badge above it countsunreadTopics + pendingApprovalsCount. With 0 unread notifications and 3 pending approvals the bell read "3" and the click showed "You're all caught up".On every open the popover now picks its tab, in this order:
"Has something in it" means what each tab shows by default: an unread notification topic for Notifications (the Unread filter is the default, and unread topics are the badge's first addend), a pending approval for Approvals (the badge's second addend), and an activity row for Activity.
The pick happens in the popover's open handler (
handleOpenChange), never while it is open, so a count that changes under an open popover does not move the tab. Only a tab the user selects (handleTabChange) is remembered. The tab the popover picks for them on open is not remembered.No new export, no new locale key, no new prop.
InboxPopoveris not exported from the package. The other caller, theglobal:notificationspage block, gets the same behaviour."Keeps the user's choice within a session": the definition pinned here
Once the user selects a tab, every later open of an inbox bell in the same browser tab reopens on it, across navigation and reload. A new browser tab starts from the opening pick again. The pick is held in
sessionStorage(keyinbox-popover-tab). Nothing goes to localStorage or to the server.Why not component state: the header bell is a different React instance on Home, Organizations, an organization's pages, the AI page and inside an app.
AppHeaderis mounted separately byHomeLayout,OrganizationsLayout,OrganizationLayout,AiChatPageandConsoleLayout, so component state would forget the pick the first time the user moved between Home and an app.sessionStorageis the scope the ChatDock's expanded flag already uses for the same kind of per-tab UI posture (chatDockState.ts). Every storage touch is guarded, so private mode degrades to "nothing picked".This survives a reload, which goes further than the dispatch's example ("until reload"). The reason is above. If the seat prefers page lifetime, the change is to hold the pick in a module variable instead.
Two acceptance texts, one edge where they disagree
The triage grade says the popover "keeps the user's choice within a session". The card's Expected says "'You're all caught up' is never the first thing shown under a non-zero badge". They disagree in one case: the user picked Notifications, later reads the notification elsewhere, and 3 approvals are still pending. Keeping the pick would show "You're all caught up" under a "3".
Step 1 above settles it: a pick is kept only while its tab has something in it. Otherwise the first tab with items wins. If every tab is empty, the pick is kept, since the badge is then 0. This keeps both texts true everywhere they can both be true. It is pinned by the case named "a picked tab that is now empty yields to the first tab with items".
The dispatch's mechanism hypotheses, measured
Tabs valueistab). Confirmed onorigin/main6be0f7a.useInboxBell. That hook reads the shared polled feeds (useSharedInboxFeed,useSharedPendingApprovalsCount), which start when the header mounts and do not depend on the popover being open. The badge renders from those same props, so the pick at open time reads the numbers the bell showed when it was clicked. If the user opens before the first poll lands, the badge is 0 too, and the popover opens on Notifications or Activity. When counts arrive while the popover is open, the tab does not move. That is pinned.Tests
New suite
packages/app-shell/src/layout/__tests__/InboxPopover.openingTab-11698.test.tsx, 11 cases. It mocks nothing: the real Radix popover and tabs, the real router and the real English i18n pack. The selected tab is read from Radix'saria-selected. The cases:InboxPopover.displayLocale-10668.test.tsxchanged in its test helper only. It read "the Notifications panel" as whatever the bell opened on. Its first case selects Activity, and that pick now persists insessionStorageinto the en-US control, so the control read the Activity panel twice: 1 of 2 timestamps, red. The helper now selects Notifications explicitly before reading it. Its assertions are unchanged.Reverse verification, on committed
770c375. A trap-guarded script wrote the base version ofInboxPopover.tsxover the fix. Landing was proven on disk:onOpenChange={setOpen}was found once, andopeningTab(andhandleOpenChangezero times. The new suite then went red with 7 failed and 4 passed, the same split as the pre-fix run, and the card's pin failed withexpected 'Notifications' to match /approvals/i. The file was restored withgit checkout HEAD, and the restore was proven by its blob hash equalling the HEAD blob and an emptygit diff HEAD. The 4 cases that pass on the base version are the ones the old constant default already satisfied: 2 and 3 open on Notifications, all empty with nothing picked opens on Notifications, a reopen within one mount keeps a pick, and an all-empty pick is kept.Gates (all on
770c375, run from the worktree root)pnpm exec vitest run --maxWorkers=2over the narrowed set below (viaos-verify-lock)Test Files 56 passed (56)·Tests 414 passed (414)pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2(the dependency closure the type-check reads)Tasks: 28 successful, 28 totalpnpm --filter @object-ui/app-shell type-check(the script nametype-checkis echoed; it chainstsc --noEmitandtsc -p tsconfig.test.json, and the test project's file list includes both touched suites)error TSlinespnpm --filter @object-ui/console exec vite buildapps/console/dist/index.htmlpnpm exec eslint --format jsonover the 3 touched.ts/.tsxfilespnpm check:control-bytescheck-control-bytes: OK (scanned 7691 tracked text file(s); skipped 85 binary).pnpm check:test-path-rootscheck-test-path-roots: OKpnpm check:changeset-claimsNo pending changeset names a file this change touches.pnpm check:pending-changeset-literalsNo test source names a pending changeset.pnpm check:vi-mock-specifierscheck-vi-mock-specifiers: OKpnpm check:new-line-citationsVERDICT new-cross-file-line-citations: 0 new citation(s)node scripts/check-changeset-presence.mjs3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)Narrowed, and declared as narrowed. The whole app-shell package would hold the shared verify lock for about 30 minutes, so the vitest run covers every test directory and file that mounts the popover. That is every file under
packages/app-shell/src/layout/, which holds all theInboxPopover*,AppHeader*andConsoleLayout*suites, plusglobal-page-blocks.render(theglobal:notificationsblock),sharedInboxFeed.twoSurfaces,HomePage.approvalsTarget,KeyboardShortcutsDialog.wiredOnly-11674(it mountsConsoleLayout) and the console'sapprovalsInboxComponentRef. The set was chosen withgit grepforInboxPopover,AppHeaderandglobal:notificationsover test files. Like any grep, it cannot see a test that mounts the header through a path that names none of them. The full farm belongs to CI.ESLint was narrowed and proven. The population comes from
eslint.config.jsitself: the touched.ts/.tsxfiles fall under its**/*.{ts,tsx}blocks. The file count is 3, read from--format json, with 0 errors and 0 warnings. The invariance holds because--print-configshows emptyparserOptions(noprojectorprojectService). Type-aware linting is off, so this diff cannot move a verdict on any untouched file.The console compiles
InboxPopover.tsxthrough its@object-ui/app-shellalias tosrc/. That compile was measured with the same command CI's E2E job uses,pnpm --filter @object-ui/console exec vite build. It exited 0 and wroteapps/console/dist/index.html. The diff adds no import, so the bundle graph the console's build checks read is unchanged.Acceptance notes
@object-ui/app-shellpatch changeset.changeset/11698-inbox-opens-on-items.md.node scripts/check-changeset-presence.mjs: 3 published source files of 1 released package, 1 changeset. Not governed:check-governed-queue-guard --testover the 4 paths printed NOT GOVERNED.Session:
https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8Generated by Claude Code