Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/11698-inbox-opens-on-items.md
Original file line number Diff line number Diff line change
@@ -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 the tab the user last selected, or on Notifications if none was selected. 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.
89 changes: 86 additions & 3 deletions packages/app-shell/src/layout/InboxPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InboxTab, boolean>, 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;
Expand Down Expand Up @@ -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<InboxTab>('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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -241,7 +324,7 @@ export function InboxPopover({
);

return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"
Expand Down Expand Up @@ -312,7 +395,7 @@ export function InboxPopover({
</span>
</div>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)} className="w-full">
<Tabs value={tab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-1 h-9">
<TabsTrigger value="notifications" className="text-xs gap-1.5 data-[state=active]:bg-transparent">
<Bell className="h-3.5 w-3.5" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,17 @@ const count = (haystack: string, needle: string) => haystack.split(needle).lengt
function openAndRead(): { notificationsTab: string; activityTab: string } {
const [bell] = screen.getAllByRole('button');
fireEvent.click(bell);
// Radix tabs activate on a primary-button mouse down. Both tabs are selected
// explicitly: the tab the bell opens on follows the counts and the user's
// last pick in this browser-tab session (objectui#11698), and an earlier
// case's pick of Activity is still in sessionStorage.
const select = (name: RegExp) =>
act(() => {
fireEvent.mouseDown(screen.getByRole('tab', { name }), { button: 0, ctrlKey: false });
});
select(/notifications/i);
const notificationsTab = screen.getByRole('dialog').textContent ?? '';
// Radix tabs activate on a primary-button mouse down.
act(() => {
fireEvent.mouseDown(screen.getByRole('tab', { name: /activity/i }), { button: 0, ctrlKey: false });
});
select(/activity/i);
const activityTab = screen.getByRole('dialog').textContent ?? '';
return { notificationsTab, activityTab };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The inbox opens on the tab that has something in it (objectui#11698).
*
* The popover's tab was a `useState('notifications')` that nothing ever moved,
* while the bell badge it sits under is `unreadTopics + pendingApprovalsCount`.
* So with 0 unread notifications and 3 pending approvals the bell read "3" and
* the first thing the click showed was "You're all caught up" — the three
* items were one tab to the right.
*
* On open the popover now picks, in order: the tab the user last chose in this
* browser-tab session if that tab has something in it, otherwise the first tab
* that does (Notifications, then Approvals, then Activity), otherwise the
* user's last choice, otherwise Notifications. "Has something in it" is what
* each tab shows by default: an unread notification topic (the Unread filter
* is the default, and it is the badge's first addend), a pending approval (the
* badge's second addend), an activity row. Only a tab the user picks counts as
* a choice; the tab the popover picked for them on open does not.
*
* Nothing is mocked: the real Radix popover and tabs, the real router and the
* real i18n provider, so the selected tab is the one Radix reports as
* `aria-selected`, and the panel text is the English pack's.
*/

import React from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';

import { InboxPopover, type InboxNotification } from '../InboxPopover';
import type { ActivityItem } from '../ActivityFeed';

beforeEach(() => window.sessionStorage.clear());
afterEach(() => {
cleanup();
window.sessionStorage.clear();
});

const CAUGHT_UP = "You're all caught up";

const unread = (n: number): InboxNotification[] =>
Array.from({ length: n }, (_, i) => ({
id: `n${i}`,
type: `topic.${i}`,
title: `Topic ${i}`,
is_read: false,
created_at: '2026-10-01T10:00:00Z',
}));

const activity = (n: number): ActivityItem[] =>
Array.from({ length: n }, (_, i) => ({
id: `a${i}`,
type: 'update',
objectName: 'account',
user: 'Priya',
description: `updated record ${i}`,
timestamp: '2026-10-01T10:00:00Z',
}));

interface Inbox {
notifications: InboxNotification[];
pendingApprovalsCount: number;
activities: ActivityItem[];
}

function bell(inbox: Inbox) {
return (
<MemoryRouter>
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<InboxPopover
notifications={inbox.notifications}
unreadCount={inbox.notifications.filter((n) => !n.is_read).length}
pendingApprovalsCount={inbox.pendingApprovalsCount}
activities={inbox.activities}
onMarkAllRead={() => {}}
onMarkRead={() => {}}
/>
</I18nProvider>
</MemoryRouter>
);
}

const trigger = () => screen.getByRole('button', { name: 'Open inbox' });

/** Click the bell open and report the selected tab and the visible panel text. */
function open(): { selected: string; panel: string } {
fireEvent.click(trigger());
const dialog = screen.getByRole('dialog');
const selected = within(dialog).getByRole('tab', { selected: true }).textContent ?? '';
const panel = within(dialog).getByRole('tabpanel').textContent ?? '';
return { selected, panel };
}

function close() {
fireEvent.click(trigger());
expect(screen.queryByRole('dialog')).toBeNull();
}

/** Pick a tab the way a pointer does (Radix tabs activate on a primary mouse down). */
function choose(name: RegExp) {
act(() => {
fireEvent.mouseDown(within(screen.getByRole('dialog')).getByRole('tab', { name }), {
button: 0,
ctrlKey: false,
});
});
expect(within(screen.getByRole('dialog')).getByRole('tab', { selected: true }).textContent).toMatch(name);
}

describe('InboxPopover — opens on the first tab with items (objectui#11698)', () => {
/** THE PIN from the card: 0 notifications, 3 pending approvals. */
it('0 notifications and 3 pending approvals: the badge reads 3 and opening shows Approvals', () => {
render(bell({ notifications: [], pendingApprovalsCount: 3, activities: activity(2) }));
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('3');

const { selected, panel } = open();
expect(selected).toMatch(/approvals/i);
expect(panel).toContain('3 pending approvals');
expect(panel).toContain('View approvals');
expect(screen.getByRole('dialog').textContent).not.toContain(CAUGHT_UP);
});

it('2 notifications and 3 pending approvals: Notifications comes first', () => {
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
const { selected, panel } = open();
expect(selected).toMatch(/notifications/i);
expect(panel).toContain('Topic 0');
});

it('only activity: opening shows Activity, not an empty Notifications tab', () => {
render(bell({ notifications: [], pendingApprovalsCount: 0, activities: activity(2) }));
const { selected, panel } = open();
expect(selected).toMatch(/activity/i);
expect(panel).toContain('updated record 0');
});

it('read notifications are not items: the Unread filter would show them as caught up', () => {
const allRead = unread(2).map((n) => ({ ...n, is_read: true }));
render(bell({ notifications: allRead, pendingApprovalsCount: 3, activities: [] }));
expect(open().selected).toMatch(/approvals/i);
});

it('everything empty: falls back to Notifications', () => {
render(bell({ notifications: [], pendingApprovalsCount: 0, activities: [] }));
const { selected, panel } = open();
expect(selected).toMatch(/notifications/i);
expect(panel).toContain(CAUGHT_UP);
});

it('re-picks on every open while the user has chosen nothing (the popover pick is not a choice)', () => {
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
expect(open().selected).toMatch(/approvals/i);
close();

// A notification arrives while the popover is closed.
rerender(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
expect(open().selected).toMatch(/notifications/i);
});

it('does not move the tab under the user while the popover stays open', () => {
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
expect(open().selected).toMatch(/approvals/i);

rerender(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
expect(within(screen.getByRole('dialog')).getByRole('tab', { selected: true }).textContent).toMatch(
/approvals/i,
);
});
});

describe("InboxPopover — keeps the user's tab within the session (objectui#11698)", () => {
it('reopening keeps the tab the user picked, over the first tab with items', () => {
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
expect(open().selected).toMatch(/notifications/i);
choose(/activity/i);
close();

const { selected, panel } = open();
expect(selected).toMatch(/activity/i);
expect(panel).toContain('updated record 0');
});

it('a remounted bell in the same browser-tab session keeps the pick (Home and an app mount different bells)', () => {
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
open();
choose(/approvals/i);
cleanup();

render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
expect(open().selected).toMatch(/approvals/i);
});

it('a picked tab that is now empty yields to the first tab with items: never "caught up" under a non-zero badge', () => {
const { rerender } = render(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
open();
choose(/approvals/i);
choose(/notifications/i);
close();

// The user read the notification elsewhere; the approvals are still pending.
rerender(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('3');
const { selected } = open();
expect(selected).toMatch(/approvals/i);
expect(screen.getByRole('dialog').textContent).not.toContain(CAUGHT_UP);
});

it('with every tab empty, the picked tab is kept', () => {
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 0, activities: activity(1) }));
open();
choose(/approvals/i);
close();

rerender(bell({ notifications: [], pendingApprovalsCount: 0, activities: [] }));
expect(open().selected).toMatch(/approvals/i);
});
});
Loading