diff --git a/src/dashboard/react-components/MessageList.tsx b/src/dashboard/react-components/MessageList.tsx index 3aee9f1f..fd83ad7e 100644 --- a/src/dashboard/react-components/MessageList.tsx +++ b/src/dashboard/react-components/MessageList.tsx @@ -43,6 +43,7 @@ SyntaxHighlighter.registerLanguage('dockerfile', docker); import type { Message, Agent, Attachment } from '../types'; import { MessageStatusIndicator } from './MessageStatusIndicator'; import { ThinkingIndicator } from './ThinkingIndicator'; +import { deduplicateBroadcasts } from './hooks/useBroadcastDedup'; // Provider icons and colors matching landing page const PROVIDER_CONFIG: Record = { @@ -127,7 +128,7 @@ export function MessageList({ const isScrollingRef = useRef(false); // Filter messages for current channel or current thread - const filteredMessages = messages.filter((msg) => { + const channelFilteredMessages = messages.filter((msg) => { // When a thread is selected, show messages related to that thread if (currentThread) { // Show the original message (id matches thread) or replies (thread field matches) @@ -148,6 +149,14 @@ export function MessageList({ return msg.from === currentChannel || msg.to === currentChannel; }); + // Deduplicate broadcast messages in #general channel + // When a broadcast is sent to '*', the backend delivers it to each recipient separately, + // causing the same message to appear multiple times. Deduplication removes duplicates + // by grouping broadcasts with the same sender, content, and timestamp. + const filteredMessages = currentChannel === 'general' + ? deduplicateBroadcasts(channelFilteredMessages) + : channelFilteredMessages; + // Populate latestMessageToAgent with the latest message from current user to each agent // Iterate in order (oldest to newest) so the last one wins for (const msg of filteredMessages) { diff --git a/src/dashboard/react-components/hooks/useBroadcastDedup.test.ts b/src/dashboard/react-components/hooks/useBroadcastDedup.test.ts new file mode 100644 index 00000000..579602f3 --- /dev/null +++ b/src/dashboard/react-components/hooks/useBroadcastDedup.test.ts @@ -0,0 +1,371 @@ +/** + * Tests for broadcast message deduplication in #general channel + * + * TDD approach: Write failing tests first, then fix the implementation. + * + * Problem: When a broadcast is sent (to='*'), the backend delivers it to each + * recipient separately. Each delivery gets a unique ID and is stored separately. + * In #general channel, this causes the same message to appear multiple times + * (once per recipient). + * + * Solution: Deduplicate broadcast messages by grouping those with the same + * sender, content, and approximate timestamp (within 1 second). + */ + +import { describe, it, expect } from 'vitest'; +import type { Message } from '../../types'; +import { deduplicateBroadcasts, getBroadcastKey } from './useBroadcastDedup'; + +// Helper to create test messages +function createMessage( + from: string, + to: string, + content: string, + options?: { + id?: string; + isBroadcast?: boolean; + timestamp?: string; + channel?: string; + } +): Message { + return { + id: options?.id || `msg-${Math.random().toString(36).slice(2)}`, + from, + to, + content, + timestamp: options?.timestamp || new Date().toISOString(), + isBroadcast: options?.isBroadcast, + channel: options?.channel, + }; +} + +describe('Broadcast Deduplication', () => { + describe('deduplicateBroadcasts', () => { + it('should show broadcast message only once when delivered to multiple recipients', () => { + const timestamp = '2026-01-08T12:00:00.000Z'; + + // Same broadcast delivered to 3 different recipients + const messages: Message[] = [ + createMessage('Alice', 'Agent1', 'Hello everyone!', { + id: 'delivery-1', + isBroadcast: true, + timestamp, + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'Hello everyone!', { + id: 'delivery-2', + isBroadcast: true, + timestamp, + channel: 'general', + }), + createMessage('Alice', 'Agent3', 'Hello everyone!', { + id: 'delivery-3', + isBroadcast: true, + timestamp, + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Should only show one instance of the broadcast + expect(deduped).toHaveLength(1); + expect(deduped[0].content).toBe('Hello everyone!'); + expect(deduped[0].from).toBe('Alice'); + }); + + it('should preserve different broadcasts from the same sender', () => { + const timestamp1 = '2026-01-08T12:00:00.000Z'; + const timestamp2 = '2026-01-08T12:01:00.000Z'; // 1 minute later + + const messages: Message[] = [ + // First broadcast delivered to 2 recipients + createMessage('Alice', 'Agent1', 'First message', { + id: 'delivery-1', + isBroadcast: true, + timestamp: timestamp1, + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'First message', { + id: 'delivery-2', + isBroadcast: true, + timestamp: timestamp1, + channel: 'general', + }), + // Second broadcast delivered to 2 recipients + createMessage('Alice', 'Agent1', 'Second message', { + id: 'delivery-3', + isBroadcast: true, + timestamp: timestamp2, + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'Second message', { + id: 'delivery-4', + isBroadcast: true, + timestamp: timestamp2, + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Should show both broadcasts, but only once each + expect(deduped).toHaveLength(2); + expect(deduped.map(m => m.content)).toContain('First message'); + expect(deduped.map(m => m.content)).toContain('Second message'); + }); + + it('should not deduplicate non-broadcast messages', () => { + const timestamp = '2026-01-08T12:00:00.000Z'; + + // Direct messages with same content should NOT be deduplicated + const messages: Message[] = [ + createMessage('Alice', 'Bob', 'Hello!', { + id: 'dm-1', + isBroadcast: false, + timestamp, + }), + createMessage('Alice', 'Charlie', 'Hello!', { + id: 'dm-2', + isBroadcast: false, + timestamp, + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Both DMs should remain (they're intentionally separate messages) + expect(deduped).toHaveLength(2); + }); + + it('should preserve message order after deduplication', () => { + const messages: Message[] = [ + createMessage('Alice', 'Agent1', 'First', { + id: 'msg-1', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.000Z', + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'First', { + id: 'msg-2', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.000Z', + channel: 'general', + }), + createMessage('Bob', 'Agent1', 'Second', { + id: 'msg-3', + isBroadcast: true, + timestamp: '2026-01-08T12:00:30.000Z', + channel: 'general', + }), + createMessage('Alice', 'Agent1', 'Third', { + id: 'msg-4', + isBroadcast: true, + timestamp: '2026-01-08T12:01:00.000Z', + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + expect(deduped).toHaveLength(3); + expect(deduped[0].content).toBe('First'); + expect(deduped[1].content).toBe('Second'); + expect(deduped[2].content).toBe('Third'); + }); + + it('should handle mixed broadcast and direct messages', () => { + const messages: Message[] = [ + // Broadcast delivered to 2 recipients + createMessage('Alice', 'Agent1', 'Broadcast', { + id: 'broadcast-1', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.000Z', + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'Broadcast', { + id: 'broadcast-2', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.000Z', + channel: 'general', + }), + // Direct message + createMessage('Bob', 'Alice', 'DM to Alice', { + id: 'dm-1', + isBroadcast: false, + timestamp: '2026-01-08T12:00:30.000Z', + }), + // Another broadcast + createMessage('Charlie', 'Agent1', 'Another broadcast', { + id: 'broadcast-3', + isBroadcast: true, + timestamp: '2026-01-08T12:01:00.000Z', + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + expect(deduped).toHaveLength(3); + expect(deduped.map(m => m.content)).toEqual([ + 'Broadcast', + 'DM to Alice', + 'Another broadcast', + ]); + }); + + it('should keep first occurrence when deduplicating', () => { + const timestamp = '2026-01-08T12:00:00.000Z'; + + const messages: Message[] = [ + createMessage('Alice', 'Agent1', 'Hello everyone!', { + id: 'first-delivery', + isBroadcast: true, + timestamp, + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'Hello everyone!', { + id: 'second-delivery', + isBroadcast: true, + timestamp, + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Should keep the first one (first-delivery) + expect(deduped).toHaveLength(1); + expect(deduped[0].id).toBe('first-delivery'); + }); + + it('should handle empty message array', () => { + const deduped = deduplicateBroadcasts([]); + expect(deduped).toHaveLength(0); + }); + + it('should handle messages with to="*" as broadcasts even without isBroadcast flag', () => { + const timestamp = '2026-01-08T12:00:00.000Z'; + + // Messages with to='*' but no isBroadcast flag (legacy format) + const messages: Message[] = [ + createMessage('Alice', '*', 'Broadcast message', { + id: 'delivery-1', + timestamp, + channel: 'general', + }), + // Same message delivered to specific agent with isBroadcast flag + createMessage('Alice', 'Agent1', 'Broadcast message', { + id: 'delivery-2', + isBroadcast: true, + timestamp, + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Should deduplicate - both represent the same broadcast + expect(deduped).toHaveLength(1); + }); + + it('should differentiate broadcasts with same content but different senders', () => { + const timestamp = '2026-01-08T12:00:00.000Z'; + + const messages: Message[] = [ + createMessage('Alice', 'Agent1', 'Hello!', { + id: 'alice-1', + isBroadcast: true, + timestamp, + channel: 'general', + }), + createMessage('Bob', 'Agent1', 'Hello!', { + id: 'bob-1', + isBroadcast: true, + timestamp, + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // Different senders, so both should appear + expect(deduped).toHaveLength(2); + }); + + it('should handle timestamps within 1 second as same broadcast', () => { + // Timestamps within 1 second should be grouped + const messages: Message[] = [ + createMessage('Alice', 'Agent1', 'Quick message', { + id: 'delivery-1', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.100Z', + channel: 'general', + }), + createMessage('Alice', 'Agent2', 'Quick message', { + id: 'delivery-2', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.500Z', + channel: 'general', + }), + createMessage('Alice', 'Agent3', 'Quick message', { + id: 'delivery-3', + isBroadcast: true, + timestamp: '2026-01-08T12:00:00.900Z', + channel: 'general', + }), + ]; + + const deduped = deduplicateBroadcasts(messages); + + // All within 1 second, same sender/content - should be 1 message + expect(deduped).toHaveLength(1); + }); + }); + + describe('getBroadcastKey', () => { + it('should generate consistent keys for same sender/content/timestamp', () => { + const msg1 = createMessage('Alice', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + const msg2 = createMessage('Alice', 'Agent2', 'Hello', { + timestamp: '2026-01-08T12:00:00.500Z', // Same second + }); + + expect(getBroadcastKey(msg1)).toBe(getBroadcastKey(msg2)); + }); + + it('should generate different keys for different senders', () => { + const msg1 = createMessage('Alice', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + const msg2 = createMessage('Bob', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + + expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2)); + }); + + it('should generate different keys for different content', () => { + const msg1 = createMessage('Alice', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + const msg2 = createMessage('Alice', 'Agent1', 'Goodbye', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + + expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2)); + }); + + it('should generate different keys for timestamps more than 1 second apart', () => { + const msg1 = createMessage('Alice', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:00.000Z', + }); + const msg2 = createMessage('Alice', 'Agent1', 'Hello', { + timestamp: '2026-01-08T12:00:02.000Z', // 2 seconds later + }); + + expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2)); + }); + }); +}); diff --git a/src/dashboard/react-components/hooks/useBroadcastDedup.ts b/src/dashboard/react-components/hooks/useBroadcastDedup.ts new file mode 100644 index 00000000..8aad74f9 --- /dev/null +++ b/src/dashboard/react-components/hooks/useBroadcastDedup.ts @@ -0,0 +1,86 @@ +/** + * Broadcast message deduplication utilities + * + * When a broadcast is sent (to='*'), the backend delivers it to each + * recipient separately. Each delivery gets a unique ID and is stored separately. + * In #general channel, this causes the same message to appear multiple times + * (once per recipient). + * + * This module provides utilities to deduplicate broadcast messages by grouping + * those with the same sender, content, and approximate timestamp. + */ + +import { useMemo } from 'react'; +import type { Message } from '../../types'; + +/** + * Check if a message is a broadcast message. + * A message is considered a broadcast if: + * - isBroadcast flag is true, OR + * - to field is '*' + */ +function isBroadcastMessage(message: Message): boolean { + return message.isBroadcast === true || message.to === '*'; +} + +/** + * Generate a deduplication key for broadcast messages. + * Uses sender + content + timestamp bucket (1-second window). + * + * @param message The message to generate a key for + * @returns A string key for deduplication + */ +export function getBroadcastKey(message: Message): string { + const timestampBucket = Math.floor(new Date(message.timestamp).getTime() / 1000); + return `${message.from}:${timestampBucket}:${message.content}`; +} + +/** + * Deduplicate broadcast messages. + * + * When a broadcast is sent, it gets delivered to each recipient separately, + * resulting in multiple stored messages with the same content. This function + * deduplicates them by grouping broadcasts with the same: + * - from (sender) + * - content (message body) + * - timestamp (within 1 second window) + * + * Non-broadcast messages (direct messages) are preserved unchanged. + * Message order is maintained, keeping the first occurrence of each broadcast. + * + * @param messages Array of messages to deduplicate + * @returns Deduplicated array with broadcast duplicates removed + */ +export function deduplicateBroadcasts(messages: Message[]): Message[] { + const seenBroadcastKeys = new Set(); + const result: Message[] = []; + + for (const message of messages) { + // Non-broadcast messages pass through unchanged + if (!isBroadcastMessage(message)) { + result.push(message); + continue; + } + + // For broadcasts, check if we've seen this key before + const key = getBroadcastKey(message); + if (!seenBroadcastKeys.has(key)) { + seenBroadcastKeys.add(key); + result.push(message); + } + // If key already seen, skip this duplicate + } + + return result; +} + +/** + * Hook for using broadcast deduplication with React state. + * Uses useMemo to prevent unnecessary recalculations when messages haven't changed. + * + * @param messages Array of messages to deduplicate + * @returns Deduplicated messages + */ +export function useBroadcastDedup(messages: Message[]): Message[] { + return useMemo(() => deduplicateBroadcasts(messages), [messages]); +}