From 97c4beb74aabc78aaa4e95d1615419cf2a92f7f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:53:49 +0000 Subject: [PATCH 1/3] fix(plugin-detail): the discussion header shows no count when a feed read was refused Claude-Session: https://claude.ai/code/session_01Utp6Cagj5WeiH9xGApkpYm Co-authored-by: Claude --- .../src/RecordActivityTimeline.tsx | 11 +- .../plugin-detail/src/RecordChatterPanel.tsx | 11 +- ...atterPanel.refusedReadCount-27820.test.tsx | 126 ++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index 87e9bd8933f..8eec08ca266 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -778,9 +778,14 @@ export const RecordActivityTimeline: React.FC = ({

{title} - - ({filtered.length}) - + {/* A refused read leaves the count unknown, not zero: the rows + the reader may not see are not in `filtered`, so a number here + would state what the server withheld. */} + {!activityDenied && !commentsDenied && ( + + ({filtered.length}) + + )}

{isStream && ( diff --git a/ui/packages/plugin-detail/src/RecordChatterPanel.tsx b/ui/packages/plugin-detail/src/RecordChatterPanel.tsx index 6cb2c2189e0..a7f772d2d87 100644 --- a/ui/packages/plugin-detail/src/RecordChatterPanel.tsx +++ b/ui/packages/plugin-detail/src/RecordChatterPanel.tsx @@ -122,6 +122,13 @@ export const RecordChatterPanel: React.FC = ({ */ const feedAria = useRecordAriaProps(config?.feed?.aria); + // A refused read leaves the count unknown, not zero (see the timeline's + // header), so the collapsed toggle names the panel without one. + const showLabel = + activityDenied || commentsDenied + ? t('detail.openDiscussion') + : t('detail.showDiscussion', { count: items.length }); + const isSidebar = position === 'right' || position === 'left'; const isInline = position === 'bottom'; @@ -218,10 +225,10 @@ export const RecordChatterPanel: React.FC = ({ variant="ghost" className="w-full justify-start gap-2 text-muted-foreground" onClick={() => setCollapsed(false)} - aria-label={t('detail.showDiscussion', { count: items.length })} + aria-label={showLabel} > - {t('detail.showDiscussion', { count: items.length })} + {showLabel} ) : (
diff --git a/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx new file mode 100644 index 00000000000..29fdc8680ac --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * The discussion header shows no count when a feed read was refused + * (objectstack#27820). + * + * The timeline's heading drew `(N)` from the rows it holds. A refused comment + * read leaves no rows, so a member who may not read the comments saw + * `Discussion (0)` right above "You don't have permission to view comments on + * this record." — a count the server never gave. The host's refusal signal + * (`commentsDenied` / `activityDenied`, the same one that draws the refusal + * line) now withholds the count; a readable feed keeps it, `(0)` included. + * + * Read through the real `en` pack and the `record:chatter` block under a host + * `DiscussionContext`, the way the record page mounts it. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { DiscussionContextProvider, RecordContextProvider } from '@object-ui/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordChatterRenderer } from '../renderers/record-chatter'; +import { RecordChatterPanel } from '../RecordChatterPanel'; + +const COMMENTS_DENIED = "You don't have permission to view comments on this record."; +const ACTIVITY_DENIED = "You don't have permission to view activity on this record."; + +const comment = (n: number) => + ({ + id: `c-${n}`, + type: 'comment', + actor: 'Ada', + body: `Comment ${n}`, + createdAt: `2026-01-0${n}T00:00:00.000Z`, + }) satisfies FeedItem; + +function mountChatter(ctx: { items: FeedItem[]; commentsDenied?: boolean; activityDenied?: boolean }) { + return render( + + + + + + + , + ); +} + +const heading = () => screen.getByRole('heading', { level: 2 }); + +afterEach(() => cleanup()); + +describe('the discussion header withholds its count when a feed read was refused (objectstack#27820)', () => { + it('a refused comment read: the header shows no count, the refusal line still shows', () => { + mountChatter({ items: [], commentsDenied: true }); + + expect(heading().textContent?.trim()).toBe('Discussion'); + expect(heading().textContent).not.toMatch(/\(\d+\)/); + expect(screen.queryByTestId('activity-count')).toBeNull(); + expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED); + }); + + it('a refused comment read beside readable activity rows still shows no count', () => { + const row = { + id: 'a-1', + type: 'field_change', + actor: 'Grace', + body: 'Stage moved to qualified', + createdAt: '2026-01-02T00:00:00.000Z', + } satisfies FeedItem; + mountChatter({ items: [row], commentsDenied: true }); + + expect(screen.getByText('Stage moved to qualified')).toBeTruthy(); + expect(heading().textContent).not.toMatch(/\(\d+\)/); + expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED); + }); + + it('a refused activity read shows no count either', () => { + mountChatter({ items: [], activityDenied: true }); + + expect(heading().textContent).not.toMatch(/\(\d+\)/); + expect(screen.getByTestId('activity-access-denied').textContent).toContain(ACTIVITY_DENIED); + }); + + it('control: a readable record with no comments shows (0)', () => { + mountChatter({ items: [] }); + + expect(heading()).toHaveTextContent(/^Discussion\s*\(0\)$/); + expect(screen.queryByTestId('activity-access-denied')).toBeNull(); + }); + + it('control: a readable record with 3 comments shows (3)', () => { + mountChatter({ items: [comment(1), comment(2), comment(3)] }); + + expect(heading()).toHaveTextContent(/^Discussion\s*\(3\)$/); + }); +}); + +describe('the collapsed discussion toggle withholds its count the same way (objectstack#27820)', () => { + const mountCollapsed = (props: { commentsDenied?: boolean }) => + render( + + + , + ); + + it('refused: the toggle names the panel without a count', () => { + mountCollapsed({ commentsDenied: true }); + + const toggle = screen.getByRole('button', { name: 'Open discussion panel' }); + expect(toggle.textContent).not.toMatch(/\(\d+\)/); + }); + + it('control: readable, the toggle keeps its (0)', () => { + mountCollapsed({}); + + expect(screen.getByRole('button', { name: 'Show Discussion (0)' })).toBeTruthy(); + }); +}); From ed1cb337d0ae14cf173f82fbdbe6c33b9e038d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:54:56 +0000 Subject: [PATCH 2/3] chore(changeset): plugin-detail patch for the refused-read discussion count Claude-Session: https://claude.ai/code/session_01Utp6Cagj5WeiH9xGApkpYm Co-authored-by: Claude --- .changeset/27820-discussion-count-refused-read.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/27820-discussion-count-refused-read.md diff --git a/.changeset/27820-discussion-count-refused-read.md b/.changeset/27820-discussion-count-refused-read.md new file mode 100644 index 00000000000..5df0af2f339 --- /dev/null +++ b/.changeset/27820-discussion-count-refused-read.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(plugin-detail): the discussion header shows no count when a feed read was refused + +Clause-②: no +Metadata-protocol: none + +A member who may not read a record's comments saw `Discussion (0)` directly above "You don't have permission to view comments on this record." The timeline's heading counted the rows it holds, and a refused read holds none, so the header claimed the record had no comments when the server only withheld them. When the host reports a refused comment or activity read, the timeline heading now shows its title with no count, and the collapsed discussion toggle reads "Open discussion panel" instead of `Show Discussion (0)`. A readable feed keeps its count, `(0)` included. From 64fcb00fadcfe52ef2c4639506055d2654a26fc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:13:42 +0000 Subject: [PATCH 3/3] test(plugin-detail): type the host feed items as the DiscussionContext takes them Claude-Session: https://claude.ai/code/session_01Utp6Cagj5WeiH9xGApkpYm Co-authored-by: Claude --- .../RecordChatterPanel.refusedReadCount-27820.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx index 29fdc8680ac..1b636143e6f 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx @@ -37,7 +37,9 @@ const comment = (n: number) => createdAt: `2026-01-0${n}T00:00:00.000Z`, }) satisfies FeedItem; -function mountChatter(ctx: { items: FeedItem[]; commentsDenied?: boolean; activityDenied?: boolean }) { +type HostItems = React.ComponentProps['items']; + +function mountChatter(ctx: { items: HostItems; commentsDenied?: boolean; activityDenied?: boolean }) { return render(