Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 8d4ed86

Browse files
committed
fix(app-shell,plugin-detail): the record page counts each comment's replies on read, and a comment with none offers the reply input (objectui#12144)
The record page mapped each sys_comment row to a feed item with its parentId and never a replyCount, while the timeline takes every reply out of the top-level list and draws a comment's replies, and the reply input with them, only under a replyCount above zero. So a stored reply showed nowhere, and no comment read from the server offered the reply input. - RecordDetailView counts each comment's children in the rows its sys_comment read returned and sets replyCount beside parentId, as the sys_comment object definition prescribes (count parent_id children at read time). - RecordActivityTimeline offers the reply input on every comment row the host hands onAddReply for, replies or not, and on no other row. The reply list keeps its one replyCount gate. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a19eaa commit 8d4ed86

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

‎packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,18 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
21102110
}
21112111
}
21122112
}
2113+
// objectui#12144: each comment's reply count is its children in the
2114+
// rows this read returned. `sys_comment` stores no count: its object
2115+
// definition says to count `parent_id` children at read time. With
2116+
// threading on, the timeline shows a comment's replies only under a
2117+
// `replyCount` above zero, so without this a stored reply was shown
2118+
// nowhere. Counted the way the timeline groups replies: by the row's
2119+
// `parent_id`, when it has one. A reply whose parent is not among
2120+
// these rows counts toward no item here.
2121+
const replyCounts = new Map<unknown, number>();
2122+
for (const c of res.data as Array<{ parent_id?: unknown }>) {
2123+
if (c.parent_id) replyCounts.set(c.parent_id, (replyCounts.get(c.parent_id) ?? 0) + 1);
2124+
}
21132125
const mapped: FeedItem[] = res.data.map((c: any) => ({
21142126
id: c.id,
21152127
type: 'comment' as const,
@@ -2119,6 +2131,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
21192131
createdAt: c.created_at,
21202132
updatedAt: c.updated_at,
21212133
parentId: c.parent_id ?? undefined,
2134+
replyCount: replyCounts.get(c.id),
21222135
reactions:
21232136
reactionStore === 'records'
21242137
? toReactions(records?.stored.get(String(c.id)))

‎packages/plugin-detail/src/RecordActivityTimeline.tsx‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,22 @@ function canStoreReaction(item: FeedItem): boolean {
198198
return item.type === 'comment';
199199
}
200200

201+
/**
202+
* Whether a feed row can take a reply (objectui#12144).
203+
*
204+
* A reply is a `sys_comment` row whose `parent_id` names the comment it
205+
* answers, so only a `comment` row offers the reply input. A row read from
206+
* `sys_activity` offers none: a reply there would store a comment whose parent
207+
* is an activity, which no read shows under it. Same reasoning as
208+
* {@link canStoreReaction}.
209+
*/
210+
function canTakeReply(item: FeedItem): boolean {
211+
return item.type === 'comment';
212+
}
213+
214+
/** The reply list a row shows while its `replyCount` is zero (objectui#12144). */
215+
const NO_REPLIES: FeedItem[] = [];
216+
201217
function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] {
202218
switch (mode) {
203219
case 'comments_only':
@@ -532,7 +548,14 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
532548
const Icon = FEED_TYPE_ICONS[item.type] || Zap;
533549
const colorClass =
534550
FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600';
535-
const replies = repliesByParent.get(item.id) ?? [];
551+
// Threading (objectui#12144). The reply LIST shows only under a
552+
// `replyCount` above zero, the one threading gate: the host that
553+
// read the rows sets it. The reply INPUT does not wait for a
554+
// first reply, so a comment with none offers it wherever the
555+
// host hands over `onAddReply`.
556+
const replies =
557+
(item.replyCount ?? 0) > 0 ? (repliesByParent.get(item.id) ?? NO_REPLIES) : NO_REPLIES;
558+
const offersReply = !!onAddReply && canTakeReply(item);
536559
// Undefined on a row that cannot store a reaction: no Add
537560
// reaction button, and its chips render disabled (objectui#11035).
538561
const toggleReaction =
@@ -631,12 +654,12 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
631654
</div>
632655

633656
{/* Threading */}
634-
{enableThreading && (item.replyCount ?? 0) > 0 && (
657+
{enableThreading && (replies.length > 0 || offersReply) && (
635658
<ThreadedReplies
636659
parentItem={item}
637660
replies={replies}
638661
onAddReply={onAddReply}
639-
showReplyInput={!!onAddReply}
662+
showReplyInput={offersReply}
640663
/>
641664
)}
642665
</div>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
* The reply input does not wait for a first reply, and the reply list keeps its
11+
* one `replyCount` gate (objectui#12144).
12+
*
13+
* With threading on, the timeline drew `ThreadedReplies`, the only place the
14+
* reply input renders, only under an item whose `replyCount` was above zero.
15+
* Nothing set it on a comment with no replies, so such a comment never offered
16+
* the input and its first reply could not be posted. Now:
17+
*
18+
* - the INPUT is offered on every comment row the host hands `onAddReply` for,
19+
* replies or not, and on no other row (a reply is a `sys_comment` whose
20+
* parent is a comment);
21+
* - the LIST still shows only under a `replyCount` above zero, so whether a
22+
* comment's replies show never depends on whether the caller may post one.
23+
*
24+
* The record page's read is what sets `replyCount`; that half is pinned through
25+
* the page in `@object-ui/app-shell`
26+
* (`RecordDetailView.replyCountOnRead-12144.test.tsx`). This file pins the
27+
* timeline's render condition alone.
28+
*
29+
* Real subjects: `RecordActivityTimeline` and `ThreadedReplies`; the activity
30+
* row is built by `activityRowToFeedItem`, the reading the record page merges
31+
* `sys_activity` rows through.
32+
*/
33+
34+
import * as React from 'react';
35+
import { describe, it, expect, vi, afterEach } from 'vitest';
36+
import { render, screen, cleanup, within } from '@testing-library/react';
37+
import type { FeedItem } from '@object-ui/types';
38+
import { RecordActivityTimeline } from '../RecordActivityTimeline';
39+
import { activityRowToFeedItem } from '../renderers/recordActivityFeed';
40+
import { DETAIL_DEFAULT_TRANSLATIONS } from '../useDetailTranslation';
41+
42+
const REPLY_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.replyPlaceholder'];
43+
const PARENT_BODY = 'Parent comment';
44+
const REPLY_BODY = 'A reply to the parent';
45+
const ACTIVITY_SUMMARY = 'Stage changed';
46+
47+
const PARENT: FeedItem = {
48+
id: 'c1',
49+
type: 'comment',
50+
actor: 'Grace',
51+
body: PARENT_BODY,
52+
createdAt: '2026-10-01T08:00:00.000Z',
53+
};
54+
55+
const REPLY: FeedItem = {
56+
id: 'c2',
57+
type: 'comment',
58+
actor: 'Ada',
59+
body: REPLY_BODY,
60+
createdAt: '2026-10-01T09:00:00.000Z',
61+
parentId: 'c1',
62+
};
63+
64+
const ACTIVITY = activityRowToFeedItem(
65+
{ id: 'a1', type: 'updated', summary: ACTIVITY_SUMMARY, actor_name: 'Ada', timestamp: '2026-10-01T07:00:00.000Z' },
66+
'System',
67+
) as FeedItem;
68+
69+
function mount(items: FeedItem[], { threading = true, onAddReply }: { threading?: boolean; onAddReply?: () => void } = {}) {
70+
render(<RecordActivityTimeline items={items} config={{ enableThreading: threading }} onAddReply={onAddReply} />);
71+
}
72+
73+
/** The feed row a body sits in: the keyed root element holding the row and its replies. */
74+
function rootRowOf(body: string): HTMLElement {
75+
const rows = screen.getByText(body).closest('.space-y-4')!.children;
76+
const row = Array.from(rows).find((r) => r.textContent?.includes(body));
77+
if (!row) throw new Error(`no feed row holds ${JSON.stringify(body)}`);
78+
return row as HTMLElement;
79+
}
80+
81+
const replyInputs = () => screen.queryAllByPlaceholderText(REPLY_PLACEHOLDER);
82+
const replyToggles = () => screen.queryAllByRole('button', { name: /\d+ repl/ });
83+
84+
afterEach(() => {
85+
cleanup();
86+
});
87+
88+
describe('the reply input does not wait for a first reply (objectui#12144)', () => {
89+
it('a comment with no replies offers it when the host hands over onAddReply', () => {
90+
mount([PARENT], { onAddReply: vi.fn() });
91+
expect(within(rootRowOf(PARENT_BODY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).not.toBeNull();
92+
expect(replyToggles()).toHaveLength(0);
93+
});
94+
95+
it('CONTROL: without onAddReply the same comment offers none', () => {
96+
mount([PARENT]);
97+
expect(replyInputs()).toHaveLength(0);
98+
});
99+
100+
it('a row that is not a comment offers none, beside a comment that does', () => {
101+
expect(ACTIVITY.type).not.toBe('comment');
102+
mount([ACTIVITY, PARENT], { onAddReply: vi.fn() });
103+
expect(within(rootRowOf(ACTIVITY_SUMMARY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).toBeNull();
104+
expect(replyInputs()).toHaveLength(1);
105+
});
106+
107+
it('CONTROL: with threading off no row offers it', () => {
108+
mount([PARENT], { threading: false, onAddReply: vi.fn() });
109+
expect(replyInputs()).toHaveLength(0);
110+
});
111+
});
112+
113+
describe("the reply list keeps its one replyCount gate (objectui#12144)", () => {
114+
it('replies the host handed over without a replyCount stay hidden, whether or not the input is offered', () => {
115+
mount([PARENT, REPLY], { onAddReply: vi.fn() });
116+
expect(replyToggles()).toHaveLength(0);
117+
expect(screen.queryByText(REPLY_BODY)).toBeNull();
118+
expect(replyInputs()).toHaveLength(1);
119+
});
120+
121+
it('with a replyCount above zero the list shows, and it shows without the input too', () => {
122+
mount([{ ...PARENT, replyCount: 1 }, REPLY]);
123+
expect(replyInputs()).toHaveLength(0);
124+
expect(within(rootRowOf(PARENT_BODY)).getAllByRole('button', { name: /1 reply/ })).toHaveLength(1);
125+
});
126+
});

0 commit comments

Comments
 (0)