From 3d0fe15ea9c27a1c81f4c4dc5276e057b37b470f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:35:38 +0000 Subject: [PATCH 1/7] Initial plan From 56cf48aef943052a43cc4f1717bf7e364a42bbd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:48:16 +0000 Subject: [PATCH 2/7] Task 1: Add comprehensive unit tests for 6 Feed/Chatter components (96 tests) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/FieldChangeItem.test.tsx | 47 +++++ .../src/__tests__/ReactionPicker.test.tsx | 44 +++++ .../__tests__/RecordActivityTimeline.test.tsx | 181 ++++++++++++++++++ .../src/__tests__/RecordChatterPanel.test.tsx | 98 ++++++++++ .../src/__tests__/SubscriptionToggle.test.tsx | 33 ++++ .../src/__tests__/ThreadedReplies.test.tsx | 104 ++++++++++ 6 files changed, 507 insertions(+) diff --git a/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx b/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx index a12ad081e..02c460799 100644 --- a/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx +++ b/packages/plugin-detail/src/__tests__/FieldChangeItem.test.tsx @@ -69,4 +69,51 @@ describe('FieldChangeItem', () => { const { container } = render(); expect(container.firstChild).toHaveClass('custom-class'); }); + + it('should render arrow icon between old and new values', () => { + const change: FieldChangeEntry = { + field: 'status', + fieldLabel: 'Status', + oldValue: 'Open', + newValue: 'Closed', + }; + const { container } = render(); + // ArrowRight renders as an SVG with lucide classes + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should render old value with line-through style', () => { + const change: FieldChangeEntry = { + field: 'status', + fieldLabel: 'Status', + oldDisplayValue: 'Open', + newDisplayValue: 'Closed', + }; + render(); + const oldEl = screen.getByText('Open'); + expect(oldEl).toHaveClass('line-through'); + }); + + it('should use fieldLabel priority over auto-generated label', () => { + const change: FieldChangeEntry = { + field: 'first_name', + fieldLabel: 'Custom Label', + oldValue: 'A', + newValue: 'B', + }; + render(); + expect(screen.getByText('Custom Label')).toBeInTheDocument(); + expect(screen.queryByText('First name')).not.toBeInTheDocument(); + }); + + it('should show (empty) for both null old and new values', () => { + const change: FieldChangeEntry = { + field: 'notes', + fieldLabel: 'Notes', + }; + render(); + const emptyTexts = screen.getAllByText('(empty)'); + expect(emptyTexts).toHaveLength(2); + }); }); diff --git a/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx b/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx index 2632c43d6..72fb82c8b 100644 --- a/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx +++ b/packages/plugin-detail/src/__tests__/ReactionPicker.test.tsx @@ -66,4 +66,48 @@ describe('ReactionPicker', () => { fireEvent.click(options[0]); expect(onToggle).toHaveBeenCalledWith('πŸ‘'); }); + + it('should disable reaction buttons when no onToggleReaction', () => { + render(); + const thumbsBtn = screen.getByLabelText(/πŸ‘ 3/); + expect(thumbsBtn).toBeDisabled(); + const heartBtn = screen.getByLabelText(/❀️ 1/); + expect(heartBtn).toBeDisabled(); + }); + + it('should render custom emojiOptions', () => { + const onToggle = vi.fn(); + const customEmoji = ['πŸš€', 'πŸ”₯', 'βœ…']; + render( + , + ); + fireEvent.click(screen.getByLabelText('Add reaction')); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('πŸš€'); + expect(options[1]).toHaveTextContent('πŸ”₯'); + expect(options[2]).toHaveTextContent('βœ…'); + }); + + it('should include emoji and count in aria-label', () => { + render(); + expect(screen.getByLabelText('πŸ‘ 3 reactions')).toBeInTheDocument(); + expect(screen.getByLabelText('❀️ 1 reaction')).toBeInTheDocument(); + }); + + it('should show non-reacted emoji with bg-muted style', () => { + render(); + const heart = screen.getByLabelText(/❀️ 1/); + expect(heart).toHaveClass('bg-muted'); + }); + + it('should close picker after selecting emoji', () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('Add reaction')); + expect(screen.getByRole('listbox', { name: 'Emoji picker' })).toBeInTheDocument(); + const options = screen.getAllByRole('option'); + fireEvent.click(options[0]); + expect(screen.queryByRole('listbox', { name: 'Emoji picker' })).not.toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx index f36348daa..24597bdfe 100644 --- a/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.test.tsx @@ -50,6 +50,16 @@ const mockItems: FeedItem[] = [ }, ]; +const allTypeItems: FeedItem[] = [ + { id: 'c1', type: 'comment', actor: 'A', body: 'comment', createdAt: '2026-02-20T10:00:00Z' }, + { id: 'fc1', type: 'field_change', actor: 'B', createdAt: '2026-02-20T10:01:00Z', fieldChanges: [{ field: 'x', oldValue: '1', newValue: '2' }] }, + { id: 't1', type: 'task', actor: 'C', body: 'task', createdAt: '2026-02-20T10:02:00Z' }, + { id: 'e1', type: 'event', actor: 'D', body: 'event', createdAt: '2026-02-20T10:03:00Z' }, + { id: 's1', type: 'system', actor: 'E', body: 'system', createdAt: '2026-02-20T10:04:00Z' }, + { id: 'm1', type: 'email', actor: 'F', body: 'email', createdAt: '2026-02-20T10:05:00Z' }, + { id: 'p1', type: 'call', actor: 'G', body: 'call', createdAt: '2026-02-20T10:06:00Z' }, +]; + describe('RecordActivityTimeline', () => { it('should render activity heading with count', () => { render(); @@ -206,4 +216,175 @@ describe('RecordActivityTimeline', () => { ); expect(screen.getByLabelText('Unsubscribe from notifications')).toBeInTheDocument(); }); + + it('should render all 7 item types', () => { + render(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.getByText('E')).toBeInTheDocument(); + expect(screen.getByText('F')).toBeInTheDocument(); + expect(screen.getByText('G')).toBeInTheDocument(); + }); + + it('should render actor avatar when actorAvatarUrl is provided', () => { + const items: FeedItem[] = [ + { id: '1', type: 'comment', actor: 'Alice', actorAvatarUrl: 'https://example.com/alice.png', body: 'Hi', createdAt: '2026-02-20T10:00:00Z' }, + ]; + render(); + const img = screen.getByAltText('Alice'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/alice.png'); + }); + + it('should submit comment via Ctrl+Enter', () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/); + fireEvent.change(textarea, { target: { value: 'Ctrl enter test' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + expect(onAdd).toHaveBeenCalledWith('Ctrl enter test'); + }); + + it('should clear input after successful comment submission', async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'Will be cleared' } }); + fireEvent.click(screen.getByLabelText('Submit comment')); + await vi.waitFor(() => { + expect(textarea.value).toBe(''); + }); + }); + + it('should disable input and button during submission', async () => { + let resolveSubmit: () => void; + const onAdd = vi.fn(() => new Promise((r) => { resolveSubmit = r; })); + render( + , + ); + const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'submitting' } }); + fireEvent.click(screen.getByLabelText('Submit comment')); + expect(textarea).toBeDisabled(); + expect(screen.getByLabelText('Submit comment')).toBeDisabled(); + resolveSubmit!(); + await vi.waitFor(() => { + expect(textarea).not.toBeDisabled(); + }); + }); + + it('should show loading spinner when loading more', async () => { + let resolveLoad: () => void; + const onLoadMore = vi.fn(() => new Promise((r) => { resolveLoad = r; })); + render( + , + ); + fireEvent.click(screen.getByLabelText('Load more activity')); + // Loader2 spinner should be present while loading + expect(screen.getByLabelText('Load more activity')).toBeDisabled(); + resolveLoad!(); + await vi.waitFor(() => { + expect(screen.getByLabelText('Load more activity')).not.toBeDisabled(); + }); + }); + + it('should use controlled filterMode and call onFilterChange', () => { + const onFilterChange = vi.fn(); + render( + , + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + // Change filter via select + fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'all' } }); + expect(onFilterChange).toHaveBeenCalledWith('all'); + }); + + it('should use internal filter state when no controlled filterMode', () => { + render( + , + ); + // Initially all visible + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + // Switch to comments_only + fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'comments_only' } }); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + }); + + it('should not render comment input when onAddComment is not provided', () => { + render(); + expect(screen.queryByPlaceholderText(/Leave a comment/)).not.toBeInTheDocument(); + }); + + it('should render reactions when enableReactions is true', () => { + const items: FeedItem[] = [ + { + id: '1', + type: 'comment', + actor: 'Alice', + body: 'Nice!', + createdAt: '2026-02-20T10:00:00Z', + reactions: [{ emoji: 'πŸ‘', count: 2, reacted: true }], + }, + ]; + render( + , + ); + expect(screen.getByText('πŸ‘')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('should not render reactions when enableReactions is false', () => { + const items: FeedItem[] = [ + { + id: '1', + type: 'comment', + actor: 'Alice', + body: 'Nice!', + createdAt: '2026-02-20T10:00:00Z', + reactions: [{ emoji: 'πŸ‘', count: 2, reacted: true }], + }, + ]; + render( + , + ); + expect(screen.queryByText('πŸ‘')).not.toBeInTheDocument(); + }); + + it('should group items by parentId when enableThreading is true', () => { + const items: FeedItem[] = [ + { id: 'p1', type: 'comment', actor: 'Alice', body: 'Root comment', createdAt: '2026-02-20T10:00:00Z', replyCount: 1 }, + { id: 'r1', type: 'comment', actor: 'Bob', body: 'Reply', createdAt: '2026-02-20T11:00:00Z', parentId: 'p1' }, + ]; + render( + , + ); + // Root comment rendered + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Root comment')).toBeInTheDocument(); + // Reply is shown as threaded reply (collapsed) + expect(screen.getByText('1 reply')).toBeInTheDocument(); + // Reply actor should not be directly visible (collapsed in ThreadedReplies) + expect(screen.queryByText('Bob')).not.toBeInTheDocument(); + }); + + it('should show "via {source}" label', () => { + const items: FeedItem[] = [ + { id: '1', type: 'comment', actor: 'Alice', body: 'Hi', createdAt: '2026-02-20T10:00:00Z', source: 'slack' }, + ]; + render(); + expect(screen.getByText('via slack')).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx index d13e958c4..ed06f54d7 100644 --- a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx @@ -126,4 +126,102 @@ describe('RecordChatterPanel', () => { expect(screen.queryByLabelText('Filter activity')).not.toBeInTheDocument(); }); }); + + describe('left sidebar mode', () => { + it('should render with border-r in left position', () => { + const { container } = render( + , + ); + const panel = container.firstChild as HTMLElement; + expect(panel).toHaveClass('border-r'); + }); + }); + + describe('right sidebar width', () => { + it('should apply configured width via style', () => { + const { container } = render( + , + ); + const panel = container.firstChild as HTMLElement; + expect(panel.style.width).toBe('400px'); + }); + }); + + describe('collapsible=false', () => { + it('should not show collapse button when collapsible is false', () => { + render( + , + ); + expect(screen.getByText('Discussion')).toBeInTheDocument(); + expect(screen.queryByLabelText('Close discussion panel')).not.toBeInTheDocument(); + }); + }); + + describe('sidebar timeline styling', () => { + it('should pass border-0 shadow-none to embedded timeline', () => { + const { container } = render( + , + ); + // The RecordActivityTimeline renders a Card; in sidebar mode it gets border-0 shadow-none + const card = container.querySelector('.border-0.shadow-none'); + expect(card).toBeInTheDocument(); + }); + }); + + describe('callback passthrough', () => { + it('should forward onAddComment to embedded timeline', () => { + const onAddComment = vi.fn().mockResolvedValue(undefined); + render( + , + ); + expect(screen.getByPlaceholderText(/Leave a comment/)).toBeInTheDocument(); + }); + }); + + describe('inline collapsible buttons', () => { + it('should show "Show Discussion (N)" when collapsed inline', () => { + render( + , + ); + expect(screen.getByText('Show Discussion (2)')).toBeInTheDocument(); + }); + + it('should show "Hide discussion" button when expanded inline', () => { + render( + , + ); + expect(screen.getByLabelText('Hide discussion')).toBeInTheDocument(); + }); + + it('should toggle between collapsed and expanded inline', () => { + render( + , + ); + // Collapsed + expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Show discussion')); + // Expanded + expect(screen.getByText('Activity')).toBeInTheDocument(); + // Click hide + fireEvent.click(screen.getByLabelText('Hide discussion')); + // Collapsed again + expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx b/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx index 845c7e2dd..455f0b4b5 100644 --- a/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx +++ b/packages/plugin-detail/src/__tests__/SubscriptionToggle.test.tsx @@ -48,4 +48,37 @@ describe('SubscriptionToggle', () => { render(); expect(screen.getByRole('button')).toBeDisabled(); }); + + it('should show title for subscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: true }; + render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribed β€” click to unsubscribe'); + }); + + it('should show title for unsubscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: false }; + render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribe to notifications'); + }); + + it('should be disabled during loading after click', async () => { + let resolveToggle: () => void; + const onToggle = vi.fn(() => new Promise((r) => { resolveToggle = r; })); + const sub: RecordSubscription = { recordId: '1', subscribed: false }; + render(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button')).toBeDisabled(); + resolveToggle!(); + await vi.waitFor(() => { + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + }); + + it('should update aria-label based on subscribed state', () => { + const sub: RecordSubscription = { recordId: '1', subscribed: true }; + const { rerender } = render( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unsubscribe from notifications'); + rerender( {}} />); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Subscribe to notifications'); + }); }); diff --git a/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx b/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx index 821cc22fa..b192870fb 100644 --- a/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx +++ b/packages/plugin-detail/src/__tests__/ThreadedReplies.test.tsx @@ -105,4 +105,108 @@ describe('ThreadedReplies', () => { ); expect(container.firstChild).toBeNull(); }); + + it('should have aria-expanded=false when collapsed', () => { + render( + , + ); + const toggle = screen.getByText('2 replies').closest('button'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should have aria-expanded=true when expanded', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + const toggle = screen.getByText('2 replies').closest('button'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should render avatarUrl when provided on reply', () => { + const repliesWithAvatar: FeedItem[] = [ + { + id: 'r1', + type: 'comment', + actor: 'Bob', + actorAvatarUrl: 'https://example.com/bob.png', + body: 'With avatar', + createdAt: '2026-02-20T11:00:00Z', + parentId: 'p1', + }, + ]; + render( + , + ); + fireEvent.click(screen.getByText('1 reply')); + const img = screen.getByAltText('Bob'); + expect(img).toHaveAttribute('src', 'https://example.com/bob.png'); + }); + + it('should render first-letter avatar fallback', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + // Bob should show 'B' as fallback initial + expect(screen.getByText('B')).toBeInTheDocument(); + }); + + it('should submit reply via Ctrl+Enter', () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…'); + fireEvent.change(input, { target: { value: 'Ctrl reply' } }); + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }); + expect(onAdd).toHaveBeenCalledWith('p1', 'Ctrl reply'); + }); + + it('should disable Send button when input is empty', () => { + const onAdd = vi.fn(); + render( + , + ); + expect(screen.getByLabelText('Send reply')).toBeDisabled(); + }); + + it('should disable input and button during reply submission', async () => { + let resolveReply: () => void; + const onAdd = vi.fn(() => new Promise((r) => { resolveReply = r; })); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'submitting reply' } }); + fireEvent.click(screen.getByLabelText('Send reply')); + expect(input).toBeDisabled(); + expect(screen.getByLabelText('Send reply')).toBeDisabled(); + resolveReply!(); + await vi.waitFor(() => { + expect(input).not.toBeDisabled(); + }); + }); + + it('should clear input after successful reply submission', async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'reply text' } }); + fireEvent.click(screen.getByLabelText('Send reply')); + await vi.waitFor(() => { + expect(input.value).toBe(''); + }); + }); + + it('should render reply body and timestamp when expanded', () => { + render( + , + ); + fireEvent.click(screen.getByText('2 replies')); + expect(screen.getByText('First reply')).toBeInTheDocument(); + expect(screen.getByText('Second reply')).toBeInTheDocument(); + }); }); From f0101727d59ceac0e2c62ff6752a8184017aa47e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:51:19 +0000 Subject: [PATCH 3/7] Task 2: Replace CommentThread with RecordChatterPanel in Console RecordDetailView Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/components/RecordDetailView.tsx | 160 +++++++++++++----- 1 file changed, 114 insertions(+), 46 deletions(-) diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index fa491d60a..131306960 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -8,14 +8,14 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; -import { DetailView } from '@object-ui/plugin-detail'; +import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; -import { CommentThread, PresenceAvatars, type Comment, type PresenceUser } from '@object-ui/collaboration'; +import { PresenceAvatars, type PresenceUser } from '@object-ui/collaboration'; import { useAuth } from '@object-ui/auth'; -import { Database, MessageSquare, Users } from 'lucide-react'; +import { Database, Users } from 'lucide-react'; import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { SkeletonDetail } from './skeletons'; -import type { DetailViewSchema } from '@object-ui/types'; +import type { DetailViewSchema, FeedItem } from '@object-ui/types'; interface RecordDetailViewProps { dataSource: any; @@ -30,8 +30,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi const { showDebug, toggleDebug } = useMetadataInspector(); const { user } = useAuth(); const [isLoading, setIsLoading] = useState(true); - const [comments, setComments] = useState([]); - const [threadResolved, setThreadResolved] = useState(false); + const [feedItems, setFeedItems] = useState([]); const [recordViewers, setRecordViewers] = useState([]); const objectDef = objects.find((o: any) => o.name === objectName); @@ -49,58 +48,126 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi .then((res: any) => { if (res.data?.length) setRecordViewers(res.data); }) .catch(() => {}); - // Fetch persisted comments + // Fetch persisted comments and map to FeedItem[] dataSource.find('sys_comment', { $filter: `threadId eq '${threadId}'`, $orderby: 'createdAt asc' }) - .then((res: any) => { if (res.data?.length) setComments(res.data); }) + .then((res: any) => { + if (res.data?.length) { + setFeedItems(res.data.map((c: any) => ({ + id: c.id, + type: 'comment' as const, + actor: c.author?.name ?? 'Unknown', + actorAvatarUrl: c.author?.avatar, + body: c.content, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + parentId: c.parentId, + reactions: c.reactions + ? Object.entries(c.reactions as Record).map(([emoji, userIds]) => ({ + emoji, + count: userIds.length, + reacted: userIds.includes(currentUser.id), + })) + : undefined, + }))); + } + }) .catch(() => {}); }, [dataSource, objectName, recordId]); const handleAddComment = useCallback( - async (content: string, mentions: string[], parentId?: string) => { - const newComment: Comment = { + async (text: string) => { + const newItem: FeedItem = { id: crypto.randomUUID(), - author: currentUser, - content, - mentions, + type: 'comment', + actor: currentUser.name, + actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined, + body: text, createdAt: new Date().toISOString(), - parentId, }; - setComments(prev => [...prev, newComment]); + setFeedItems(prev => [...prev, newItem]); // Persist to backend if (dataSource) { const threadId = `${objectName}:${recordId}`; - dataSource.create('sys_comment', { ...newComment, threadId }).catch(() => {}); + dataSource.create('sys_comment', { + id: newItem.id, + threadId, + author: currentUser, + content: text, + mentions: [], + createdAt: newItem.createdAt, + }).catch(() => {}); } }, [currentUser, dataSource, objectName, recordId], ); - const handleDeleteComment = useCallback( - async (commentId: string) => { - setComments(prev => prev.filter(c => c.id !== commentId)); + const handleAddReply = useCallback( + async (parentId: string | number, text: string) => { + const newItem: FeedItem = { + id: crypto.randomUUID(), + type: 'comment', + actor: currentUser.name, + actorAvatarUrl: 'avatar' in currentUser ? (currentUser as any).avatar : undefined, + body: text, + createdAt: new Date().toISOString(), + parentId, + }; + setFeedItems(prev => { + const updated = [...prev, newItem]; + // Increment replyCount on parent + return updated.map(item => + item.id === parentId + ? { ...item, replyCount: (item.replyCount ?? 0) + 1 } + : item + ); + }); if (dataSource) { - dataSource.delete('sys_comment', commentId).catch(() => {}); + const threadId = `${objectName}:${recordId}`; + dataSource.create('sys_comment', { + id: newItem.id, + threadId, + author: currentUser, + content: text, + mentions: [], + createdAt: newItem.createdAt, + parentId, + }).catch(() => {}); } }, - [dataSource], + [currentUser, dataSource, objectName, recordId], ); - const handleReaction = useCallback( - (commentId: string, emoji: string) => { - setComments(prev => prev.map(c => { - if (c.id !== commentId) return c; - const reactions = { ...(c.reactions || {}) }; - const userIds = reactions[emoji] || []; - if (userIds.includes(currentUser.id)) { - reactions[emoji] = userIds.filter(id => id !== currentUser.id); - if (reactions[emoji].length === 0) delete reactions[emoji]; + const handleToggleReaction = useCallback( + (itemId: string | number, emoji: string) => { + setFeedItems(prev => prev.map(item => { + if (item.id !== itemId) return item; + const reactions = [...(item.reactions ?? [])]; + const idx = reactions.findIndex(r => r.emoji === emoji); + if (idx >= 0) { + const r = reactions[idx]; + if (r.reacted) { + // Remove user's reaction + if (r.count <= 1) { + reactions.splice(idx, 1); + } else { + reactions[idx] = { ...r, count: r.count - 1, reacted: false }; + } + } else { + reactions[idx] = { ...r, count: r.count + 1, reacted: true }; + } } else { - reactions[emoji] = [...userIds, currentUser.id]; + reactions.push({ emoji, count: 1, reacted: true }); } - const updated = { ...c, reactions }; - // Persist reaction update to backend + const updated = { ...item, reactions }; + // Persist reaction update if (dataSource) { - dataSource.update('sys_comment', commentId, { reactions }).catch(() => {}); + const reactionsMap: Record = {}; + for (const r of reactions) { + reactionsMap[r.emoji] = r.reacted + ? [currentUser.id, ...Array(r.count - 1).fill('other')] + : Array(r.count).fill('other'); + } + dataSource.update('sys_comment', String(itemId), { reactions: reactionsMap }).catch(() => {}); } return updated; })); @@ -186,19 +253,20 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi {/* Comments & Discussion */}
-

- - Comments & Discussion -

-
From c196e4f01bfadfd6d5ce58492e902f6caac2a140 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:02:01 +0000 Subject: [PATCH 4/7] Task 6: Update ROADMAP test count (5,618+) and mark Feed/Chatter tests + Console integration complete Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4f10e6e47..df1fa50e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind + Shadcn. It renders JSON metadata from the @objectstack/spec protocol into pixel-perfect, accessible, and interactive enterprise interfaces. -**Where We Are:** Foundation is **solid and shipping** β€” 35 packages, 99+ components, 5,177+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder βœ…), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), and **Feed/Chatter UI** (P1.5) β€” all βœ… complete. +**Where We Are:** Foundation is **solid and shipping** β€” 35 packages, 99+ components, 5,618+ tests, 78 Storybook stories, 42/42 builds passing, ~85% protocol alignment. SpecBridge, Expression Engine, Action Engine, data binding, all view plugins (Grid/Kanban/Calendar/Gantt/Timeline/Map/Gallery), Record components, Report engine, Dashboard BI features, mobile UX, i18n (11 locales), WCAG AA accessibility, Designer Phase 1 (ViewDesigner drag-to-reorder βœ…), Console through Phase 20 (L3), **AppShell Navigation Renderer** (P0.1), **Flow Designer** (P2.4), and **Feed/Chatter UI** (P1.5) β€” all βœ… complete. **What Remains:** The gap to **Airtable-level UX** is primarily in: 1. ~~**AppShell** β€” No dynamic navigation renderer from spec JSON (last P0 blocker)~~ βœ… Complete @@ -140,6 +140,8 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] `SubscriptionToggle` β€” bell notification toggle - [x] `ReactionPicker` β€” emoji reaction selector - [x] `ThreadedReplies` β€” collapsible comment reply threading + - [x] Comprehensive unit tests for all 6 core Feed/Chatter components (96 tests) + - [x] Console `RecordDetailView` integration: `CommentThread` β†’ `RecordChatterPanel` with `FeedItem[]` data model ### P1.6 Console β€” Automation @@ -496,7 +498,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th | **AppShell Renderer** | βœ… Complete | Sidebar + nav tree from `AppSchema` JSON | Console renders from spec JSON | | **Designer Interaction** | Phase 2 (most complete) | ViewDesigner + DataModelDesigner drag/undo | Manual UX testing | | **Build Status** | 42/42 pass | 42/42 pass | `pnpm build` | -| **Test Count** | 5,070+ | 5,500+ | `pnpm test` summary | +| **Test Count** | 5,070+ | 5,618+ | `pnpm test` summary | | **Test Coverage** | 90%+ | 90%+ | `pnpm test:coverage` | | **Storybook Stories** | 78 | 91+ (1 per component) | Story file count | | **Console i18n** | 100% | 100% | No hardcoded strings | From 26b83d1cbb640302820ef836cdcc65bcde817c39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:04:46 +0000 Subject: [PATCH 5/7] Fix code review: replace placeholder userIds with atomic toggle operation for reaction persistence Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/RecordDetailView.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index 131306960..50c827106 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -159,15 +159,11 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi reactions.push({ emoji, count: 1, reacted: true }); } const updated = { ...item, reactions }; - // Persist reaction update + // Persist reaction toggle to backend if (dataSource) { - const reactionsMap: Record = {}; - for (const r of reactions) { - reactionsMap[r.emoji] = r.reacted - ? [currentUser.id, ...Array(r.count - 1).fill('other')] - : Array(r.count).fill('other'); - } - dataSource.update('sys_comment', String(itemId), { reactions: reactionsMap }).catch(() => {}); + dataSource.update('sys_comment', String(itemId), { + $toggleReaction: { emoji, userId: currentUser.id }, + }).catch(() => {}); } return updated; })); From c7257ca1b491853b3227681732932d89db698f5c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:37:31 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20RecordDetailView.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/console/src/components/RecordDetailView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index 50c827106..9227b74b4 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -72,7 +72,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi } }) .catch(() => {}); - }, [dataSource, objectName, recordId]); + }, [dataSource, objectName, recordId, currentUser]); const handleAddComment = useCallback( async (text: string) => { From 54f42c4daddb1c3062877eacd8e7958b516a5f5a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:37:42 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20ROADMAP.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index df1fa50e4..4b9a812dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,7 +142,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] `ThreadedReplies` β€” collapsible comment reply threading - [x] Comprehensive unit tests for all 6 core Feed/Chatter components (96 tests) - [x] Console `RecordDetailView` integration: `CommentThread` β†’ `RecordChatterPanel` with `FeedItem[]` data model - + - [ ] Documentation for Feed/Chatter plugin in `content/docs/plugins/plugin-detail.mdx` (purpose/use cases, JSON schema, props, and Console integration for `RecordChatterPanel`, `RecordActivityTimeline`, and related components) ### P1.6 Console β€” Automation > **Spec v3.0.9** significantly expanded the automation/workflow protocol. New node types, BPMN interop, execution tracking, and wait/timer executors are now available in the spec.