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
26 changes: 26 additions & 0 deletions .changeset/home-action-centre-needs-an-answer-4235.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

Home's action centre no longer says "You're all caught up" to a user whose inbox it failed to read (#4235)

`useHomeInbox` caught every failed `sys_inbox_message` read to `[]`, so a denial
arrived at `HomeActionCenter` wearing the exact shape of an empty inbox and the
panel reported a quiet day, with no badge, to a user with nine unread messages.
That is the reported symptom, and objectstack#7344 measured its mechanism in a
browser: `403 PERMISSION_DENIED` on that object for every non-admin session,
while `/api/v1/notifications` — a projection of the very same rows — answered
with their messages. It also resolves the cross-run contradiction the card
carried: two QA runs on one console pin disagreed because one was an admin and
one was not.

The hook now reports `notificationsStatus` (`idle` / `loading` / `ready` /
`error` — `MetadataProvider`'s vocabulary, per #4300's one-dialect ruling), and
the affirmative copy renders only on `ready`. An unanswered read gets a quiet,
non-affirmative notice instead, rendered alongside the approvals row when only
that half answered. A deployment with no inbox object at all is still an answer
and still reads as caught up, unchanged.

The source is unchanged and deliberately so: ADR-0030 names `sys_inbox_message`
as the console's consumer channel, and `/api/v1/notifications` projects the same
query one hop later.
3 changes: 2 additions & 1 deletion packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function HomePage() {
const { favorites } = useFavorites();
const { user } = useAuth();
const isAdmin = useIsWorkspaceAdmin();
const { pendingApprovalsCount, notifications, activities } = useHomeInbox();
const { pendingApprovalsCount, notifications, notificationsStatus, activities } = useHomeInbox();
// Home renders OUTSIDE the `/apps/:appName/*` router, so there is no
// `params.appName` to read — `currentAppName` (published by ConsoleLayout on
// every app mount) is the only "which app is the user in" signal available
Expand Down Expand Up @@ -462,6 +462,7 @@ export function HomePage() {
<HomeActionCenter
pendingApprovalsCount={pendingApprovalsCount}
notifications={notifications}
notificationsStatus={notificationsStatus}
onOpenApprovals={() => navigate(`/apps/${hostAppSegment}/system/approvals`)}
/* The fallback arm runs whenever a notification carries no
`action_url`; `?view=mine` selects the user-scoped view, so the
Expand Down
58 changes: 52 additions & 6 deletions packages/app-shell/src/console/home/HomeRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
*/
import {
CheckSquare, Activity, ArrowRight, CheckCheck, Bell, Clock,
FileText, Database, LayoutDashboard, File,
FileText, Database, LayoutDashboard, File, CircleAlert,
} from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed';
import type { HomeNotification } from '../../hooks/useHomeInbox';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox';
import type { RecentItem } from '../../hooks/useRecentItems';
import { timeAgo } from '../../utils/relativeTime';

Expand Down Expand Up @@ -94,28 +94,74 @@ function Row({
);
}

/**
* "You're all caught up" is an ASSERTION about the user's inbox, so it may only
* be made once the inbox has answered (#4235).
*
* `notifications` arriving empty says nothing on its own: until #4235 the hook
* behind it swallowed every failed read to `[]`, so a `403 PERMISSION_DENIED`
* on `sys_inbox_message` (objectstack#7344, browser-measured for every non-admin
* persona) reached this component wearing the exact shape of an empty inbox —
* and the panel cheerfully told a user with nine unread messages that there was
* nothing to do, with no badge. `notificationsStatus` is the missing bit, and
* gating on it is why that pair is now unreachable: an unanswered read renders
* the quiet notice below, never the affirmative copy.
*
* Same rule #4300 landed for the app list ("an unloadable app list is UNKNOWN,
* not 'no default app'"), and the same status vocabulary.
*/
export function HomeActionCenter({
pendingApprovalsCount,
notifications,
notificationsStatus,
onOpenApprovals,
onOpenNotification,
t,
}: {
pendingApprovalsCount: number;
notifications: HomeNotification[];
/**
* Required, not optional-with-a-default: a call site that cannot say whether
* its rows are an answer must not be able to reach the affirmative copy by
* saying nothing.
*/
notificationsStatus: HomeInboxStatus;
onOpenApprovals: () => void;
onOpenNotification: (n: HomeNotification) => void;
t: TFn;
}) {
const { language } = useObjectTranslation();
const total = pendingApprovalsCount + notifications.length;
const answered = notificationsStatus === 'ready';
return (
<Card icon={CheckSquare} accent count={total} title={t('home.actionCenter.title', { defaultValue: 'Needs your attention' })}>
{total === 0 ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<CheckCheck className="h-4 w-4 text-emerald-500" />
{t('home.actionCenter.empty', { defaultValue: "You're all caught up" })}
{/*
Rendered ALONGSIDE the list, not only instead of it: when approvals are
known and the inbox read failed, the panel is showing half an answer,
and saying so is the same honesty the empty case owes.
*/}
{!answered && (
<div
className="flex items-center gap-2 py-2 text-sm text-muted-foreground"
data-testid="home-action-unanswered"
>
{notificationsStatus === 'error' ? (
<>
<CircleAlert className="h-4 w-4 text-amber-500" />
{t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })}
</>
) : (
t('common.loading', { defaultValue: 'Loading...' })
)}
</div>
)}
{total === 0 ? (
answered && (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<CheckCheck className="h-4 w-4 text-emerald-500" />
{t('home.actionCenter.empty', { defaultValue: "You're all caught up" })}
</div>
)
) : (
<ul className="flex flex-col gap-0.5">
{pendingApprovalsCount > 0 && (
Expand Down
Loading
Loading