From 228f9819b332a6d91738355f69bd7341d75b7041 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 17:28:50 +0000 Subject: [PATCH 1/3] fix(app-shell): the inbox opens on the first tab with items (objectui#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 --- .changeset/11698-inbox-opens-on-items.md | 9 + .../app-shell/src/layout/InboxPopover.tsx | 89 ++++++- .../InboxPopover.openingTab-11698.test.tsx | 219 ++++++++++++++++++ 3 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 .changeset/11698-inbox-opens-on-items.md create mode 100644 packages/app-shell/src/layout/__tests__/InboxPopover.openingTab-11698.test.tsx diff --git a/.changeset/11698-inbox-opens-on-items.md b/.changeset/11698-inbox-opens-on-items.md new file mode 100644 index 0000000000..73f4ab9679 --- /dev/null +++ b/.changeset/11698-inbox-opens-on-items.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +The inbox bell opens on the tab that has something in it (objectui#11698). + +The popover always opened on Notifications. With no unread notifications and three pending approvals, the bell read "3" and the click showed "You're all caught up", with the three approvals behind the next tab. + +On each open the popover now picks the first tab with items, in the order Notifications (an unread notification), Approvals (a pending approval), Activity (an activity row). When every tab is empty it opens on Notifications. A tab the user selects is remembered for the browser tab's session and reopens first, unless it is empty while another tab has items. The tab does not change while the popover is open. diff --git a/packages/app-shell/src/layout/InboxPopover.tsx b/packages/app-shell/src/layout/InboxPopover.tsx index d6040125a2..6bae5e50ed 100644 --- a/packages/app-shell/src/layout/InboxPopover.tsx +++ b/packages/app-shell/src/layout/InboxPopover.tsx @@ -46,6 +46,61 @@ import type { InboxNotification, NotificationGroup } from './inboxGrouping.js'; // through this module (the pure logic itself lives in inboxGrouping.ts). export type { InboxNotification, NotificationGroup } from './inboxGrouping.js'; +type InboxTab = 'notifications' | 'approvals' | 'activity'; + +/** Tab order, which is also the order the opening pick walks. */ +const INBOX_TABS: readonly InboxTab[] = ['notifications', 'approvals', 'activity']; + +const isInboxTab = (value: unknown): value is InboxTab => + typeof value === 'string' && (INBOX_TABS as readonly string[]).includes(value); + +/** + * sessionStorage key holding the tab the user last PICKED (objectui#11698). + * + * sessionStorage, not component state: the header bell is a different + * instance on Home, Organizations, the AI page and inside an app (each layout + * mounts its own `AppHeader`), so component state would forget the pick at the + * first move between them. Not localStorage either: a fresh browser tab starts + * from the opening pick again — the same per-tab scope the ChatDock's expanded + * flag uses (`chatDockState.ts`). Every touch is guarded so private mode and a + * DOM-less runner degrade to "nothing picked". + */ +const INBOX_TAB_STORAGE_KEY = 'inbox-popover-tab'; + +function recallPickedTab(): InboxTab | null { + try { + const raw = window.sessionStorage.getItem(INBOX_TAB_STORAGE_KEY); + return isInboxTab(raw) ? raw : null; + } catch { + return null; + } +} + +function rememberPickedTab(tab: InboxTab): void { + try { + window.sessionStorage.setItem(INBOX_TAB_STORAGE_KEY, tab); + } catch { + /* storage unavailable: the pick lasts until the popover closes */ + } +} + +/** + * The tab the popover opens on (objectui#11698). It was a fixed + * `'notifications'`, so a bell reading "3" for three pending approvals opened + * on "You're all caught up" with the three items one tab to the right. + * + * In order: the user's last pick if that tab has something in it; else the + * first tab that does (Notifications, Approvals, Activity); else the user's + * last pick; else Notifications. "Has something in it" is what each tab shows + * by default — an unread topic (the Unread filter is the default, and it is the + * badge's first addend), a pending approval (the second addend), an activity + * row — so a non-zero badge never opens on an empty tab, a pick included. + */ +function openingTab(hasItems: Record, picked: InboxTab | null): InboxTab { + if (picked && hasItems[picked]) return picked; + return INBOX_TABS.find((t) => hasItems[t]) ?? picked ?? 'notifications'; +} + export interface InboxPopoverProps { notifications: InboxNotification[]; unreadCount: number; @@ -79,7 +134,9 @@ export function InboxPopover({ const { currentAppName } = useNavigationContext(); const { apps } = useMetadata(); const [open, setOpen] = useState(false); - const [tab, setTab] = useState<'notifications' | 'approvals' | 'activity'>('notifications'); + // Chosen on every open by `handleOpenChange` below, never while open, so a + // count that changes under an open popover does not move the tab. + const [tab, setTab] = useState('notifications'); // Sub-filter inside Notifications: default to Unread so users see what // actually needs their attention first. The popover caps at 20 rows from // the server (`?view=mine` already scopes to current user), so we filter @@ -120,6 +177,32 @@ export function InboxPopover({ if (!pulse) prevTotalRef.current = totalBadge; }, [pulse, totalBadge]); + // The counts are props the shared feeds already polled for the badge (the + // popover fetches nothing on open), so the pick reads the same numbers the + // bell showed when it was clicked. + const handleOpenChange = (next: boolean) => { + if (next) { + setTab( + openingTab( + { + notifications: unreadTopics > 0, + approvals: pendingApprovalsCount > 0, + activity: activities.length > 0, + }, + recallPickedTab(), + ), + ); + } + setOpen(next); + }; + + // Only a tab the user selects is remembered; the opening pick is not. + const handleTabChange = (value: string) => { + if (!isInboxTab(value)) return; + setTab(value); + rememberPickedTab(value); + }; + const goToApprovals = () => { setOpen(false); const app = currentAppName ?? params.appName; @@ -241,7 +324,7 @@ export function InboxPopover({ ); return ( - +