@agent-relay/dashboard 2.0.80 → 2.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Tests for broadcast message deduplication in #general channel
3
+ *
4
+ * TDD approach: Write failing tests first, then fix the implementation.
5
+ *
6
+ * Problem: When a broadcast is sent (to='*'), the backend delivers it to each
7
+ * recipient separately. Each delivery gets a unique ID and is stored separately.
8
+ * In #general channel, this causes the same message to appear multiple times
9
+ * (once per recipient).
10
+ *
11
+ * Solution: Deduplicate broadcast messages by grouping those with the same
12
+ * sender, content, and approximate timestamp (within 1 second).
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import type { Message } from '../../types';
17
+ import { deduplicateBroadcasts, getBroadcastKey } from './useBroadcastDedup';
18
+
19
+ // Helper to create test messages
20
+ function createMessage(
21
+ from: string,
22
+ to: string,
23
+ content: string,
24
+ options?: {
25
+ id?: string;
26
+ isBroadcast?: boolean;
27
+ timestamp?: string;
28
+ channel?: string;
29
+ }
30
+ ): Message {
31
+ return {
32
+ id: options?.id || `msg-${Math.random().toString(36).slice(2)}`,
33
+ from,
34
+ to,
35
+ content,
36
+ timestamp: options?.timestamp || new Date().toISOString(),
37
+ isBroadcast: options?.isBroadcast,
38
+ channel: options?.channel,
39
+ };
40
+ }
41
+
42
+ describe('Broadcast Deduplication', () => {
43
+ describe('deduplicateBroadcasts', () => {
44
+ it('should show broadcast message only once when delivered to multiple recipients', () => {
45
+ const timestamp = '2026-01-08T12:00:00.000Z';
46
+
47
+ // Same broadcast delivered to 3 different recipients
48
+ const messages: Message[] = [
49
+ createMessage('Alice', 'Agent1', 'Hello everyone!', {
50
+ id: 'delivery-1',
51
+ isBroadcast: true,
52
+ timestamp,
53
+ channel: 'general',
54
+ }),
55
+ createMessage('Alice', 'Agent2', 'Hello everyone!', {
56
+ id: 'delivery-2',
57
+ isBroadcast: true,
58
+ timestamp,
59
+ channel: 'general',
60
+ }),
61
+ createMessage('Alice', 'Agent3', 'Hello everyone!', {
62
+ id: 'delivery-3',
63
+ isBroadcast: true,
64
+ timestamp,
65
+ channel: 'general',
66
+ }),
67
+ ];
68
+
69
+ const deduped = deduplicateBroadcasts(messages);
70
+
71
+ // Should only show one instance of the broadcast
72
+ expect(deduped).toHaveLength(1);
73
+ expect(deduped[0].content).toBe('Hello everyone!');
74
+ expect(deduped[0].from).toBe('Alice');
75
+ });
76
+
77
+ it('should preserve different broadcasts from the same sender', () => {
78
+ const timestamp1 = '2026-01-08T12:00:00.000Z';
79
+ const timestamp2 = '2026-01-08T12:01:00.000Z'; // 1 minute later
80
+
81
+ const messages: Message[] = [
82
+ // First broadcast delivered to 2 recipients
83
+ createMessage('Alice', 'Agent1', 'First message', {
84
+ id: 'delivery-1',
85
+ isBroadcast: true,
86
+ timestamp: timestamp1,
87
+ channel: 'general',
88
+ }),
89
+ createMessage('Alice', 'Agent2', 'First message', {
90
+ id: 'delivery-2',
91
+ isBroadcast: true,
92
+ timestamp: timestamp1,
93
+ channel: 'general',
94
+ }),
95
+ // Second broadcast delivered to 2 recipients
96
+ createMessage('Alice', 'Agent1', 'Second message', {
97
+ id: 'delivery-3',
98
+ isBroadcast: true,
99
+ timestamp: timestamp2,
100
+ channel: 'general',
101
+ }),
102
+ createMessage('Alice', 'Agent2', 'Second message', {
103
+ id: 'delivery-4',
104
+ isBroadcast: true,
105
+ timestamp: timestamp2,
106
+ channel: 'general',
107
+ }),
108
+ ];
109
+
110
+ const deduped = deduplicateBroadcasts(messages);
111
+
112
+ // Should show both broadcasts, but only once each
113
+ expect(deduped).toHaveLength(2);
114
+ expect(deduped.map(m => m.content)).toContain('First message');
115
+ expect(deduped.map(m => m.content)).toContain('Second message');
116
+ });
117
+
118
+ it('should not deduplicate non-broadcast messages', () => {
119
+ const timestamp = '2026-01-08T12:00:00.000Z';
120
+
121
+ // Direct messages with same content should NOT be deduplicated
122
+ const messages: Message[] = [
123
+ createMessage('Alice', 'Bob', 'Hello!', {
124
+ id: 'dm-1',
125
+ isBroadcast: false,
126
+ timestamp,
127
+ }),
128
+ createMessage('Alice', 'Charlie', 'Hello!', {
129
+ id: 'dm-2',
130
+ isBroadcast: false,
131
+ timestamp,
132
+ }),
133
+ ];
134
+
135
+ const deduped = deduplicateBroadcasts(messages);
136
+
137
+ // Both DMs should remain (they're intentionally separate messages)
138
+ expect(deduped).toHaveLength(2);
139
+ });
140
+
141
+ it('should preserve message order after deduplication', () => {
142
+ const messages: Message[] = [
143
+ createMessage('Alice', 'Agent1', 'First', {
144
+ id: 'msg-1',
145
+ isBroadcast: true,
146
+ timestamp: '2026-01-08T12:00:00.000Z',
147
+ channel: 'general',
148
+ }),
149
+ createMessage('Alice', 'Agent2', 'First', {
150
+ id: 'msg-2',
151
+ isBroadcast: true,
152
+ timestamp: '2026-01-08T12:00:00.000Z',
153
+ channel: 'general',
154
+ }),
155
+ createMessage('Bob', 'Agent1', 'Second', {
156
+ id: 'msg-3',
157
+ isBroadcast: true,
158
+ timestamp: '2026-01-08T12:00:30.000Z',
159
+ channel: 'general',
160
+ }),
161
+ createMessage('Alice', 'Agent1', 'Third', {
162
+ id: 'msg-4',
163
+ isBroadcast: true,
164
+ timestamp: '2026-01-08T12:01:00.000Z',
165
+ channel: 'general',
166
+ }),
167
+ ];
168
+
169
+ const deduped = deduplicateBroadcasts(messages);
170
+
171
+ expect(deduped).toHaveLength(3);
172
+ expect(deduped[0].content).toBe('First');
173
+ expect(deduped[1].content).toBe('Second');
174
+ expect(deduped[2].content).toBe('Third');
175
+ });
176
+
177
+ it('should handle mixed broadcast and direct messages', () => {
178
+ const messages: Message[] = [
179
+ // Broadcast delivered to 2 recipients
180
+ createMessage('Alice', 'Agent1', 'Broadcast', {
181
+ id: 'broadcast-1',
182
+ isBroadcast: true,
183
+ timestamp: '2026-01-08T12:00:00.000Z',
184
+ channel: 'general',
185
+ }),
186
+ createMessage('Alice', 'Agent2', 'Broadcast', {
187
+ id: 'broadcast-2',
188
+ isBroadcast: true,
189
+ timestamp: '2026-01-08T12:00:00.000Z',
190
+ channel: 'general',
191
+ }),
192
+ // Direct message
193
+ createMessage('Bob', 'Alice', 'DM to Alice', {
194
+ id: 'dm-1',
195
+ isBroadcast: false,
196
+ timestamp: '2026-01-08T12:00:30.000Z',
197
+ }),
198
+ // Another broadcast
199
+ createMessage('Charlie', 'Agent1', 'Another broadcast', {
200
+ id: 'broadcast-3',
201
+ isBroadcast: true,
202
+ timestamp: '2026-01-08T12:01:00.000Z',
203
+ channel: 'general',
204
+ }),
205
+ ];
206
+
207
+ const deduped = deduplicateBroadcasts(messages);
208
+
209
+ expect(deduped).toHaveLength(3);
210
+ expect(deduped.map(m => m.content)).toEqual([
211
+ 'Broadcast',
212
+ 'DM to Alice',
213
+ 'Another broadcast',
214
+ ]);
215
+ });
216
+
217
+ it('should keep first occurrence when deduplicating', () => {
218
+ const timestamp = '2026-01-08T12:00:00.000Z';
219
+
220
+ const messages: Message[] = [
221
+ createMessage('Alice', 'Agent1', 'Hello everyone!', {
222
+ id: 'first-delivery',
223
+ isBroadcast: true,
224
+ timestamp,
225
+ channel: 'general',
226
+ }),
227
+ createMessage('Alice', 'Agent2', 'Hello everyone!', {
228
+ id: 'second-delivery',
229
+ isBroadcast: true,
230
+ timestamp,
231
+ channel: 'general',
232
+ }),
233
+ ];
234
+
235
+ const deduped = deduplicateBroadcasts(messages);
236
+
237
+ // Should keep the first one (first-delivery)
238
+ expect(deduped).toHaveLength(1);
239
+ expect(deduped[0].id).toBe('first-delivery');
240
+ });
241
+
242
+ it('should handle empty message array', () => {
243
+ const deduped = deduplicateBroadcasts([]);
244
+ expect(deduped).toHaveLength(0);
245
+ });
246
+
247
+ it('should handle messages with to="*" as broadcasts even without isBroadcast flag', () => {
248
+ const timestamp = '2026-01-08T12:00:00.000Z';
249
+
250
+ // Messages with to='*' but no isBroadcast flag (legacy format)
251
+ const messages: Message[] = [
252
+ createMessage('Alice', '*', 'Broadcast message', {
253
+ id: 'delivery-1',
254
+ timestamp,
255
+ channel: 'general',
256
+ }),
257
+ // Same message delivered to specific agent with isBroadcast flag
258
+ createMessage('Alice', 'Agent1', 'Broadcast message', {
259
+ id: 'delivery-2',
260
+ isBroadcast: true,
261
+ timestamp,
262
+ channel: 'general',
263
+ }),
264
+ ];
265
+
266
+ const deduped = deduplicateBroadcasts(messages);
267
+
268
+ // Should deduplicate - both represent the same broadcast
269
+ expect(deduped).toHaveLength(1);
270
+ });
271
+
272
+ it('should differentiate broadcasts with same content but different senders', () => {
273
+ const timestamp = '2026-01-08T12:00:00.000Z';
274
+
275
+ const messages: Message[] = [
276
+ createMessage('Alice', 'Agent1', 'Hello!', {
277
+ id: 'alice-1',
278
+ isBroadcast: true,
279
+ timestamp,
280
+ channel: 'general',
281
+ }),
282
+ createMessage('Bob', 'Agent1', 'Hello!', {
283
+ id: 'bob-1',
284
+ isBroadcast: true,
285
+ timestamp,
286
+ channel: 'general',
287
+ }),
288
+ ];
289
+
290
+ const deduped = deduplicateBroadcasts(messages);
291
+
292
+ // Different senders, so both should appear
293
+ expect(deduped).toHaveLength(2);
294
+ });
295
+
296
+ it('should handle timestamps within 1 second as same broadcast', () => {
297
+ // Timestamps within 1 second should be grouped
298
+ const messages: Message[] = [
299
+ createMessage('Alice', 'Agent1', 'Quick message', {
300
+ id: 'delivery-1',
301
+ isBroadcast: true,
302
+ timestamp: '2026-01-08T12:00:00.100Z',
303
+ channel: 'general',
304
+ }),
305
+ createMessage('Alice', 'Agent2', 'Quick message', {
306
+ id: 'delivery-2',
307
+ isBroadcast: true,
308
+ timestamp: '2026-01-08T12:00:00.500Z',
309
+ channel: 'general',
310
+ }),
311
+ createMessage('Alice', 'Agent3', 'Quick message', {
312
+ id: 'delivery-3',
313
+ isBroadcast: true,
314
+ timestamp: '2026-01-08T12:00:00.900Z',
315
+ channel: 'general',
316
+ }),
317
+ ];
318
+
319
+ const deduped = deduplicateBroadcasts(messages);
320
+
321
+ // All within 1 second, same sender/content - should be 1 message
322
+ expect(deduped).toHaveLength(1);
323
+ });
324
+ });
325
+
326
+ describe('getBroadcastKey', () => {
327
+ it('should generate consistent keys for same sender/content/timestamp', () => {
328
+ const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
329
+ timestamp: '2026-01-08T12:00:00.000Z',
330
+ });
331
+ const msg2 = createMessage('Alice', 'Agent2', 'Hello', {
332
+ timestamp: '2026-01-08T12:00:00.500Z', // Same second
333
+ });
334
+
335
+ expect(getBroadcastKey(msg1)).toBe(getBroadcastKey(msg2));
336
+ });
337
+
338
+ it('should generate different keys for different senders', () => {
339
+ const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
340
+ timestamp: '2026-01-08T12:00:00.000Z',
341
+ });
342
+ const msg2 = createMessage('Bob', 'Agent1', 'Hello', {
343
+ timestamp: '2026-01-08T12:00:00.000Z',
344
+ });
345
+
346
+ expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
347
+ });
348
+
349
+ it('should generate different keys for different content', () => {
350
+ const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
351
+ timestamp: '2026-01-08T12:00:00.000Z',
352
+ });
353
+ const msg2 = createMessage('Alice', 'Agent1', 'Goodbye', {
354
+ timestamp: '2026-01-08T12:00:00.000Z',
355
+ });
356
+
357
+ expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
358
+ });
359
+
360
+ it('should generate different keys for timestamps more than 1 second apart', () => {
361
+ const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
362
+ timestamp: '2026-01-08T12:00:00.000Z',
363
+ });
364
+ const msg2 = createMessage('Alice', 'Agent1', 'Hello', {
365
+ timestamp: '2026-01-08T12:00:02.000Z', // 2 seconds later
366
+ });
367
+
368
+ expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
369
+ });
370
+ });
371
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Broadcast message deduplication utilities
3
+ *
4
+ * When a broadcast is sent (to='*'), the backend delivers it to each
5
+ * recipient separately. Each delivery gets a unique ID and is stored separately.
6
+ * In #general channel, this causes the same message to appear multiple times
7
+ * (once per recipient).
8
+ *
9
+ * This module provides utilities to deduplicate broadcast messages by grouping
10
+ * those with the same sender, content, and approximate timestamp.
11
+ */
12
+
13
+ import { useMemo } from 'react';
14
+ import type { Message } from '../../types';
15
+
16
+ /**
17
+ * Check if a message is a broadcast message.
18
+ * A message is considered a broadcast if:
19
+ * - isBroadcast flag is true, OR
20
+ * - to field is '*'
21
+ */
22
+ function isBroadcastMessage(message: Message): boolean {
23
+ return message.isBroadcast === true || message.to === '*';
24
+ }
25
+
26
+ /**
27
+ * Generate a deduplication key for broadcast messages.
28
+ * Uses sender + content + timestamp bucket (1-second window).
29
+ *
30
+ * @param message The message to generate a key for
31
+ * @returns A string key for deduplication
32
+ */
33
+ export function getBroadcastKey(message: Message): string {
34
+ const timestampBucket = Math.floor(new Date(message.timestamp).getTime() / 1000);
35
+ return `${message.from}:${timestampBucket}:${message.content}`;
36
+ }
37
+
38
+ /**
39
+ * Deduplicate broadcast messages.
40
+ *
41
+ * When a broadcast is sent, it gets delivered to each recipient separately,
42
+ * resulting in multiple stored messages with the same content. This function
43
+ * deduplicates them by grouping broadcasts with the same:
44
+ * - from (sender)
45
+ * - content (message body)
46
+ * - timestamp (within 1 second window)
47
+ *
48
+ * Non-broadcast messages (direct messages) are preserved unchanged.
49
+ * Message order is maintained, keeping the first occurrence of each broadcast.
50
+ *
51
+ * @param messages Array of messages to deduplicate
52
+ * @returns Deduplicated array with broadcast duplicates removed
53
+ */
54
+ export function deduplicateBroadcasts(messages: Message[]): Message[] {
55
+ const seenBroadcastKeys = new Set<string>();
56
+ const result: Message[] = [];
57
+
58
+ for (const message of messages) {
59
+ // Non-broadcast messages pass through unchanged
60
+ if (!isBroadcastMessage(message)) {
61
+ result.push(message);
62
+ continue;
63
+ }
64
+
65
+ // For broadcasts, check if we've seen this key before
66
+ const key = getBroadcastKey(message);
67
+ if (!seenBroadcastKeys.has(key)) {
68
+ seenBroadcastKeys.add(key);
69
+ result.push(message);
70
+ }
71
+ // If key already seen, skip this duplicate
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Hook for using broadcast deduplication with React state.
79
+ * Uses useMemo to prevent unnecessary recalculations when messages haven't changed.
80
+ *
81
+ * @param messages Array of messages to deduplicate
82
+ * @returns Deduplicated messages
83
+ */
84
+ export function useBroadcastDedup(messages: Message[]): Message[] {
85
+ return useMemo(() => deduplicateBroadcasts(messages), [messages]);
86
+ }