Skip to content

Commit 029bdaf

Browse files
fix(app-shell): the activity feed asks only a caller who may read sys_activity, and a failed read is not an empty feed (objectui#12081 item 8) (#12095)
Part of #12081 (item 8) Clause-②: no Item 8 only, as triage directed. Items 2, 3, 4 and 7 stay on the card, and #12081 remains open. ## Measured first **objectui `main` at `023f00d4`.** `useSharedActivityFeed` sent `find('sys_activity', { $orderby: { timestamp: 'desc' }, $top: 20 })` for every signed-in user. A failure other than 404 went to `markFailed()`, so the store's status became `error`. The hook still returned `.value` alone. Three surfaces read that value: the header bell's Activity tab, the `global:notifications` page block (the same popover), and Home's activity card (through `useHumanActivityFeed`). Each received `[]` and showed "No recent activity", followed by a "View all activity" link to a list page that refuses the same user. The card says the widget "shows nothing". It actually shows the empty-state message. Reproduced on `023f00d4` with only the test file changed. The new block in `AppHeader.inboxVariant.test.tsx` was 2 red and 2 green: - "a member without the grant issues no sys_activity request…": `expected [ { object: 'sys_activity', … } ] to have a length of +0 but got 1`. - "a 403 from the server (a stale grant) reads as failed…": the failure notice was never rendered, and the DOM held "No recent activity". **objectstack `origin/main` at `86da1949`, read only.** - **The 403 is object-level.** `member_default` in plugin-security's `default-permission-sets.ts` grants read on the two inbox objects. Its own comment says "`sys_activity` is deliberately NOT included: it is not a per-user-scoped shape". `git grep sys_activity 86da1949 -- examples/` returns nothing, so no shipped example app grants it either. The admin sets reach it through their `*` entries. - **The server already narrows an allowed read.** plugin-audit's `activity-read-visibility.ts` adds a parent-record filter to every non-system `find`, `findOne`, `count` and `aggregate` on `sys_activity`. A reader who holds the object grant sees only rows about records they can open. - **So a narrower query cannot help a user without the grant.** The record Discussion tab's activity half is also a `find('sys_activity', …)`, filtered by `object_name` and `record_id`. It hits the same object check, and for that user it shows its refusal state (objectui#11195), not rows. PM mechanism assumption 2 holds. That is why this PR takes triage's second branch: decide from the user's grant before asking. ## Changes **1. The grant is checked before the read (`sharedUserFeeds.ts`).** The hook reads `usePermissions().can('sys_activity', 'read')`. This is the permission check the shell already uses for reads: `ObjectDataPage`'s route gate and `UnifiedSidebar`'s nav gate read it the same way. I looked at the affordance map from objectui#12084 and did not use it. Its rows are write affordances only, and its census explicitly leaves `can(x, 'read')` read gates outside its family. The check has three outcomes, beside the unchanged presence rule: - permissions not loaded (no provider mounted, or a provider whose refetch failed): unknown, so the feed reads, as before; - loaded and granted: the feed reads; - loaded and refused: no cache key and no request, and the hook reports `readable: false`. **2. The status reaches the surfaces.** The hook now returns `ActivityFeedReading`, which is `value`, `status` and `readable` together. `readable` is a separate field rather than a fifth status value, so the four-value status set (#4300) is unchanged. **3. The surfaces.** - `InboxPopover` takes a required `activity` prop in place of `activities`. With `readable: false` it shows no Activity tab trigger, no tab body and no "View all activity" link, and it ignores a remembered Activity tab choice. With `error` it shows "An unexpected error occurred.". With `idle` or `loading` it shows "Loading…". It shows "No recent activity" only when the status is `ready`. - `AppHeader` and `global-notifications-renderer` pass the new value through. `AppHeader`'s exported props are unchanged: rows a host passes as `activities` are treated as `ready`. - Home is an extension beyond the claimed files, named here as the dispatch asked. `useHomeInbox` returns `activity` instead of `activities`. `HomeActivity` renders nothing when `readable` is false and shows the same three states otherwise. `HomePage` removes the 360px grid column when the card is absent. **4. The cache key includes the signed-in user.** This is a small, bounded fix in the same file; the reasons are below. The messages reuse existing locale keys, `errors.unknown` and `common.loading`, the same pair `HomeActionCenter` shows for an inbox read that has not answered. No locale key is added. Nothing is added to the package entry: `sharedUserFeeds`, `InboxPopover`, `HomeRail` and `useHomeInbox` are not re-exported from `src/index.ts`. `AppHeader` is re-exported, and its props are unchanged. So Clause-② stays `no`. ### The fix in the same file: the activity cache key The store keyed the activity feed on the adapter alone. Two facts make that wrong: - the server narrows the rows per user; - signing out does not reload the page (`AuthProvider` notes that "no sign-out call site reloads the page"), so this module-level store keeps running. As a result, the next user to sign in on the same browser tab was shown the previous user's rows as `ready`, within the 30s freshness window and until a refetch landed. The key is now the adapter plus the signed-in user id, as the inbox feed's key already is. The user id is part of the key only: the query names no user, so a host without auth still reads. The test "re-keys on the signed-in user" pins it. Why this fits in this PR rather than a separate card: - it is the same defect class as item 8 (the feed must show only what this user can see); - the fix is mechanical, and its shape already exists in the same file (the inbox feed's adapter-plus-user key); - the claim names no other holder of this file; - it runs under the same gates and adds no new verification surface. ## Evidence **Head.** All runs below are on `f1f0ba557`: `023f00d4` plus this change, merged with `origin/main` at `de302c73`. That merge brought in objectui#12088 and #12090, and neither touches any file in this diff. **Type-check.** `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exits 0. **Tests, narrowed.** I ran every test file that names any of the seven changed modules or the components built on them (`sharedUserFeeds`, `useHomeInbox`, `AppHeader`, `InboxPopover`, `global-notifications-renderer` / `GlobalNotificationsRenderer`, `HomeRail`, `HomePage`, `HomeLayout`, `ConsoleLayout`, `global:notifications`), selected by `git grep -l`, plus `affordanceGrantMap-12082.test.tsx`. That is 121 files, in two runs. - First run: 60 of 61 files passed, 709 of 710 tests. - The one failure was `ApiConsolePage.requestPreset-10591`. It hit a 10s timeout while the page still showed "Initializing application…", under a full shared box. That happens before the shell renders anything this PR touches. Run alone on the same head it passes 12 of 12. - Second run: 60 of 60 files, 735 of 735 tests. The rest of app-shell's 1,250 test files are left to CI: no vi.mock of the changed modules exists outside the files above. **Reverse checks.** Each check was run from the committed `d74a507` with `ablation-replace.mjs`, which checks that the anchor matched and that the restore leaves the file identical to HEAD (`git diff HEAD` empty after each leg). The tests run were the new `objectui#12081` cases in the hook suite and the `AppHeader` suite. - Grant check forced open (`readable = true || …`): 3 red, namely the two refusal cases in the hook suite and the `AppHeader` member case. - User dropped from the cache key: 1 red, "re-keys on the signed-in user". - Popover empty message no longer gated on `ready`: 1 red, the 403 case. **Gates.** | Gate | Result | |---|---| | `check:new-line-citations` | 0 new citations | | `check:control-bytes` | OK | | `check:i18n-keys` | exit 0 | | `check-changeset-presence` | 1 changeset for 27 source files | | `check-changeset-no-major` | exit 0 | | `check-vi-mock-inherit` / `-override-shape` / `-specifiers` | OK | | `check-test-path-roots` | OK | | `check:esm-specifiers` / `check:self-import` / `check:phantom-deps` | exit 0 | **Lint.** I ran eslint on the 27 touched `.ts`/`.tsx` files only: - result (`--format json`): 27 files, 0 errors, 96 warnings, and none of the warnings is on a line this PR adds; - the full lint run is left to CI; - searching `eslint.config.js` for `projectService`, `parserOptions.project` or `TypeChecked` finds nothing, so type-aware linting is not enabled, and this diff cannot change the lint result of a file it does not touch. **Eager closure.** `check:eager-closure` passes at `d74a507`: 3169.0 KB gzipped, headroom 35.6 KB. Base `023f00d4`, built in a separate worktree, measures 3168.6 KB. The difference is +349 bytes gzipped (+1,202 raw), and the eager chunk count is 290 at both. The head figure was taken before the merge. ## Acceptance notes - The `useHomeInbox` mocks in seven `HomePage.*` tests never carried `notificationsStatus`. This PR only replaces their `activities` key. The missing key is fixture drift; it was not changed here and no card was filed. - I did not check whether `MePermissionsProvider` refetches when a different user signs in on the same tab. If it does not, the next user's grant check reads the previous user's grants. The result is then either a refused read, which now shows as an error, or a missing Activity tab until reload. This is unmeasured, so no card was filed. Who picks it up: no one. - No guide describes the bell's Activity tab, so no documentation became false. `content/docs/guide/notifications.md` covers only the notifications half. Session: `https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z` --- _Generated by [Claude Code](https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 5af42db commit 029bdaf

28 files changed

Lines changed: 718 additions & 123 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The console asks for recent activity only when the signed-in user may read it, and a failed read no longer shows as "No recent activity" (objectui#12081, item 8).
6+
7+
The header bell's Activity tab, the `global:notifications` page block and Home's activity card share one `sys_activity` read. It went out for every signed-in user on every page. A user whose permission sets grant no read on `sys_activity` (every non-admin on a deployment whose app sets do not name it) got `403` each time, and all three surfaces then said "No recent activity".
8+
9+
- **No read without the grant.** The feed now checks the user's read permission on `sys_activity` from the permissions the console already loads (`/auth/me/permissions`). A user without it gets no request. The bell offers no Activity tab, and Home shows no activity card. When permissions are not loaded (no permission provider is mounted, or the permissions request failed), the read still goes out as before.
10+
- **A failed read says so.** The Activity tab and Home's card now show "An unexpected error occurred." when the read fails, a refusal included, and "Loading…" while it is in flight. "No recent activity" appears only when the read has answered with no rows.
11+
- **The previous user's rows are not reused.** The server returns only the activity on records the reader can open. A sign-out keeps the console running, so the cached rows could be shown to the next user who signed in on the same tab. The cache is now kept per signed-in user.
12+
13+
Clause-②: no. Nothing is added to or removed from the package entry, and no locale key is added. `AppHeader`'s props are unchanged: rows a host passes as `activities` are shown as an answer, as before.

‎packages/app-shell/src/console/home/HomePage.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
resolveHostAppSegment,
4040
resolveNotificationTarget,
4141
} from '../../utils/index.js';
42-
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
42+
import { Empty, EmptyTitle, EmptyDescription, Button, cn } from '@object-ui/components';
4343
import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
4444
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
4545
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
@@ -244,7 +244,7 @@ export function HomePage() {
244244
const { favorites } = useFavorites();
245245
const { user } = useAuth();
246246
const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus();
247-
const { pendingApprovalsCount, notifications, unreadTopicCount, notificationsStatus, activities } =
247+
const { pendingApprovalsCount, notifications, unreadTopicCount, notificationsStatus, activity } =
248248
useHomeInbox();
249249
// Home renders OUTSIDE the `/apps/:appName/*` router, so there is no
250250
// `params.appName` to read — `currentAppName` (published by ConsoleLayout on
@@ -599,10 +599,18 @@ export function HomePage() {
599599
/>
600600
</div>
601601

602-
{/* Continue where you left off + ambient activity */}
603-
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
602+
{/* Continue where you left off + ambient activity. The activity column
603+
exists only for a caller who may read `sys_activity`
604+
(objectui#12081) — `HomeActivity` renders nothing otherwise, and an
605+
empty 360px column would be a hole where the card was. */}
606+
<div
607+
className={cn(
608+
'grid grid-cols-1 items-start gap-6',
609+
activity.readable && 'lg:grid-cols-[minmax(0,1fr)_360px]',
610+
)}
611+
>
604612
<HomeContinue items={recentApps} onOpen={(href) => navigate(href)} t={t} labelOf={recentLabel} />
605-
<HomeActivity items={activities} onViewAll={() => navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
613+
<HomeActivity activity={activity} onViewAll={() => navigate(`/apps/${hostAppSegment}/sys_activity`)} t={t} />
606614
</div>
607615
</div>
608616
</div>

‎packages/app-shell/src/console/home/HomeRail.tsx‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
FileText, Database, LayoutDashboard, File, CircleAlert,
1818
} from 'lucide-react';
1919
import { useDisplayLocale } from '@object-ui/i18n';
20-
import type { ActivityItem } from '../../layout/ActivityFeed.js';
20+
import type { ActivityFeedReading } from '../../hooks/sharedUserFeeds.js';
2121
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
2222
import type { RecentItem } from '../../hooks/useRecentItems.js';
2323
import type { RecentItemLabelResolver } from '../../hooks/useRecentItemLabel.js';
@@ -279,15 +279,46 @@ export function HomeContinue({
279279
);
280280
}
281281

282-
export function HomeActivity({ items, onViewAll, t }: { items: ActivityItem[]; onViewAll: () => void; t: TFn }) {
282+
/**
283+
* Home's activity card. Takes the feed's whole reading, not its rows
284+
* (objectui#12081): "No recent activity" is an ASSERTION about the feed, made
285+
* only once it has answered — the rule `HomeActionCenter` above applies to
286+
* the inbox (#4235). The rows alone could not tell a refused `sys_activity`
287+
* read from an empty feed, so every non-admin was told there was none.
288+
*
289+
* Renders nothing for a caller who may not read `sys_activity`
290+
* (`readable: false`): no card, no empty claim, and no drill into a list page
291+
* that answers them `403`. `HomePage` drops the column with it.
292+
*/
293+
export function HomeActivity({ activity, onViewAll, t }: { activity: ActivityFeedReading; onViewAll: () => void; t: TFn }) {
283294
// The display locale, as `HomeActionCenter` above (objectui#10668).
284295
const displayLocale = useDisplayLocale();
296+
if (!activity.readable) return null;
297+
const items = activity.value;
285298
return (
286299
<Card icon={Activity} title={t('sidebar.activityFeed', { defaultValue: 'Activity feed' })}>
300+
{/* Beside the list, not only in place of it, as on the action centre. */}
301+
{activity.status !== 'ready' && (
302+
<div
303+
className="flex items-center gap-2 pb-2 text-sm text-muted-foreground"
304+
data-testid="home-activity-unanswered"
305+
>
306+
{activity.status === 'error' ? (
307+
<>
308+
<CircleAlert className="h-4 w-4 text-amber-500" />
309+
{t('errors.unknown', { defaultValue: 'An unexpected error occurred.' })}
310+
</>
311+
) : (
312+
t('common.loading', { defaultValue: 'Loading…' })
313+
)}
314+
</div>
315+
)}
287316
{items.length === 0 ? (
288-
<p className="text-sm text-muted-foreground">
289-
{t('layout.activityFeed.empty', { defaultValue: 'No recent activity' })}
290-
</p>
317+
activity.status === 'ready' && (
318+
<p className="text-sm text-muted-foreground">
319+
{t('layout.activityFeed.empty', { defaultValue: 'No recent activity' })}
320+
</p>
321+
)
291322
) : (
292323
<ul className="flex flex-col gap-2.5">
293324
{items.slice(0, 5).map((a) => (

‎packages/app-shell/src/console/home/__tests__/HomeActionCenter.unansweredInbox.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ let approvalsFixture = 0;
9999
vi.mock('../../../hooks/sharedUserFeeds', async (importOriginal) => ({
100100
...(await importOriginal<Record<string, unknown>>()),
101101
useSharedPendingApprovalsCount: () => approvalsFixture,
102-
useHumanActivityFeed: () => [],
102+
useHumanActivityFeed: () => ({ value: [], status: 'ready', readable: true }),
103103
}));
104104

105105
import { useHomeInbox } from '../../../hooks/useHomeInbox';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#12081 item 8 — Home's activity card, the second consumer of the
11+
* shared activity feed.
12+
*
13+
* It took the feed's rows alone, so a refused `sys_activity` read reached it as
14+
* `[]` and it told every non-admin "No recent activity". It now takes the
15+
* feed's whole reading, and these cases pin each state of it: no card for a
16+
* caller without the grant, a failure that says so, a pending read that claims
17+
* nothing, and — the control — the earned empty copy once the feed answers.
18+
*/
19+
import '@testing-library/jest-dom/vitest';
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { render, screen } from '@testing-library/react';
22+
import { HomeActivity } from '../HomeRail';
23+
import type { ActivityFeedReading } from '../../../hooks/sharedUserFeeds';
24+
25+
const t = (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key);
26+
27+
const EMPTY_COPY = 'No recent activity';
28+
const ERROR_COPY = 'An unexpected error occurred.';
29+
// U+2026, matching `common.loading` in the en pack.
30+
const LOADING_COPY = 'Loading…';
31+
32+
const ROW = {
33+
id: 'a1',
34+
type: 'update' as const,
35+
objectName: 'crm_lead',
36+
user: 'Li Si',
37+
description: 'updated the lead',
38+
timestamp: '2026-10-10T08:00:00Z',
39+
};
40+
41+
const card = (activity: ActivityFeedReading) =>
42+
render(<HomeActivity activity={activity} onViewAll={vi.fn()} t={t} />);
43+
44+
describe("Home's activity card reads the feed's status and grant (objectui#12081)", () => {
45+
it('renders nothing for a caller who may not read sys_activity', () => {
46+
const { container } = card({ value: [], status: 'idle', readable: false });
47+
expect(container).toBeEmptyDOMElement();
48+
});
49+
50+
it('a failed read says so, and does not say "No recent activity"', () => {
51+
card({ value: [], status: 'error', readable: true });
52+
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(ERROR_COPY);
53+
expect(screen.queryByText(EMPTY_COPY)).not.toBeInTheDocument();
54+
});
55+
56+
it('a read still in flight claims nothing about the feed', () => {
57+
card({ value: [], status: 'loading', readable: true });
58+
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(LOADING_COPY);
59+
expect(screen.queryByText(EMPTY_COPY)).not.toBeInTheDocument();
60+
});
61+
62+
it('a failed re-read keeps the rows it had, beside the notice', () => {
63+
card({ value: [ROW], status: 'error', readable: true });
64+
expect(screen.getByText('updated the lead')).toBeInTheDocument();
65+
expect(screen.getByTestId('home-activity-unanswered')).toHaveTextContent(ERROR_COPY);
66+
});
67+
68+
it('an answered empty feed still says "No recent activity" (the control)', () => {
69+
card({ value: [], status: 'ready', readable: true });
70+
expect(screen.getByText(EMPTY_COPY)).toBeInTheDocument();
71+
expect(screen.queryByTestId('home-activity-unanswered')).not.toBeInTheDocument();
72+
});
73+
});

‎packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
103103
pendingApprovalsCount: 0,
104104
notifications: [],
105105
unreadTopicCount: 0,
106-
activities: [],
106+
activity: { value: [], status: 'ready', readable: true },
107107
}),
108108
}));
109109
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));

‎packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
9292
pendingApprovalsCount: 3,
9393
notifications: [],
9494
unreadTopicCount: 0,
95-
activities: [],
95+
activity: { value: [], status: 'ready', readable: true },
9696
}),
9797
}));
9898
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));

‎packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
100100
pendingApprovalsCount: 0,
101101
notifications: [],
102102
unreadTopicCount: 0,
103-
activities: [],
103+
activity: { value: [], status: 'ready', readable: true },
104104
}),
105105
}));
106106
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));

‎packages/app-shell/src/console/home/__tests__/HomePage.greetingLocale-11689.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
6767
pendingApprovalsCount: 0,
6868
notifications: [],
6969
unreadTopicCount: 0,
70-
activities: [],
70+
activity: { value: [], status: 'ready', readable: true },
7171
}),
7272
}));
7373
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));

‎packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ vi.mock('../../../hooks/useHomeInbox', () => ({
9494
pendingApprovalsCount: 0,
9595
notifications: notificationsFixture,
9696
unreadTopicCount: notificationsFixture.length,
97-
activities: activitiesFixture,
97+
activity: { value: activitiesFixture, status: 'ready', readable: true },
9898
}),
9999
}));
100100
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));

0 commit comments

Comments
 (0)