@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,1527 @@
1
+ /**
2
+ * Tests for Channels V1 API functions
3
+ *
4
+ * Tests API response handling and mapping logic.
5
+ * Uses mock fetch for testing without real API calls.
6
+ */
7
+
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import type { Channel, ChannelMessage, ChannelMember, SearchResult } from './types';
10
+
11
+ // Mock the USE_REAL_API to always be true for these tests
12
+ vi.mock('./mockApi', () => ({}));
13
+
14
+ // Test the mapping functions by extracting them
15
+ // These are the same mapping functions from api.ts
16
+
17
+ function mapChannelFromBackend(backend: unknown): Channel {
18
+ const b = backend as Record<string, unknown>;
19
+ return {
20
+ id: String(b.id || ''),
21
+ name: String(b.name || ''),
22
+ description: b.description as string | undefined,
23
+ topic: b.topic as string | undefined,
24
+ visibility: b.isPrivate ? 'private' : 'public',
25
+ status: b.isArchived ? 'archived' : 'active',
26
+ createdAt: String(b.createdAt || new Date().toISOString()),
27
+ createdBy: String(b.createdById || b.createdBy || ''),
28
+ lastActivityAt: b.lastActivityAt as string | undefined,
29
+ memberCount: Number(b.memberCount) || 0,
30
+ unreadCount: Number(b.unreadCount) || 0,
31
+ hasMentions: Boolean(b.hasMentions),
32
+ lastMessage: b.lastMessage as Channel['lastMessage'],
33
+ isDm: Boolean(b.isDm),
34
+ dmParticipants: b.dmParticipants as string[] | undefined,
35
+ };
36
+ }
37
+
38
+ function mapMessageFromBackend(backend: unknown): ChannelMessage {
39
+ const b = backend as Record<string, unknown>;
40
+ return {
41
+ id: String(b.id || ''),
42
+ channelId: String(b.channelId || ''),
43
+ from: String(b.from || b.senderName || ''),
44
+ fromEntityType: (b.fromEntityType as 'agent' | 'user') || 'user',
45
+ fromAvatarUrl: b.fromAvatarUrl as string | undefined,
46
+ content: String(b.content || b.body || ''),
47
+ timestamp: String(b.timestamp || b.createdAt || new Date().toISOString()),
48
+ editedAt: b.editedAt as string | undefined,
49
+ threadId: b.threadId as string | undefined,
50
+ threadSummary: b.threadSummary as ChannelMessage['threadSummary'],
51
+ mentions: b.mentions as string[] | undefined,
52
+ attachments: b.attachments as ChannelMessage['attachments'],
53
+ reactions: b.reactions as ChannelMessage['reactions'],
54
+ isPinned: Boolean(b.isPinned),
55
+ isRead: b.isRead !== false,
56
+ };
57
+ }
58
+
59
+ function mapSearchResultFromBackend(backend: unknown): SearchResult {
60
+ const b = backend as Record<string, unknown>;
61
+ return {
62
+ id: String(b.id || b.messageId || ''),
63
+ channelId: String(b.channelId || ''),
64
+ channelName: String(b.channelName || ''),
65
+ from: String(b.from || b.senderName || ''),
66
+ fromEntityType: (b.fromEntityType as 'agent' | 'user') || 'user',
67
+ content: String(b.content || b.body || ''),
68
+ snippet: String(b.snippet || b.headline || b.content || ''),
69
+ timestamp: String(b.timestamp || b.createdAt || new Date().toISOString()),
70
+ rank: Number(b.rank) || 0,
71
+ };
72
+ }
73
+
74
+ describe('Channel API Mapping Functions', () => {
75
+ describe('mapChannelFromBackend', () => {
76
+ it('should map basic channel fields', () => {
77
+ const backend = {
78
+ id: 'ch-123',
79
+ name: 'general',
80
+ description: 'General discussion',
81
+ isPrivate: false,
82
+ isArchived: false,
83
+ memberCount: 5,
84
+ unreadCount: 2,
85
+ createdAt: '2024-01-01T00:00:00Z',
86
+ createdById: 'user-1',
87
+ };
88
+
89
+ const result = mapChannelFromBackend(backend);
90
+
91
+ expect(result.id).toBe('ch-123');
92
+ expect(result.name).toBe('general');
93
+ expect(result.description).toBe('General discussion');
94
+ expect(result.visibility).toBe('public');
95
+ expect(result.status).toBe('active');
96
+ expect(result.memberCount).toBe(5);
97
+ expect(result.unreadCount).toBe(2);
98
+ });
99
+
100
+ it('should map private channel correctly', () => {
101
+ const backend = {
102
+ id: 'ch-private',
103
+ name: 'secret',
104
+ isPrivate: true,
105
+ };
106
+
107
+ const result = mapChannelFromBackend(backend);
108
+
109
+ expect(result.visibility).toBe('private');
110
+ });
111
+
112
+ it('should map archived channel correctly', () => {
113
+ const backend = {
114
+ id: 'ch-archived',
115
+ name: 'old-project',
116
+ isArchived: true,
117
+ };
118
+
119
+ const result = mapChannelFromBackend(backend);
120
+
121
+ expect(result.status).toBe('archived');
122
+ });
123
+
124
+ it('should map DM channel correctly', () => {
125
+ const backend = {
126
+ id: 'dm-123',
127
+ name: 'dm-alice-bob',
128
+ isDm: true,
129
+ dmParticipants: ['alice', 'bob'],
130
+ };
131
+
132
+ const result = mapChannelFromBackend(backend);
133
+
134
+ expect(result.isDm).toBe(true);
135
+ expect(result.dmParticipants).toEqual(['alice', 'bob']);
136
+ });
137
+
138
+ it('should handle missing fields gracefully', () => {
139
+ const backend = {};
140
+
141
+ const result = mapChannelFromBackend(backend);
142
+
143
+ expect(result.id).toBe('');
144
+ expect(result.name).toBe('');
145
+ expect(result.visibility).toBe('public');
146
+ expect(result.status).toBe('active');
147
+ expect(result.memberCount).toBe(0);
148
+ expect(result.unreadCount).toBe(0);
149
+ expect(result.hasMentions).toBe(false);
150
+ expect(result.isDm).toBe(false);
151
+ });
152
+
153
+ it('should map hasMentions correctly', () => {
154
+ const backend = {
155
+ id: 'ch-123',
156
+ name: 'alerts',
157
+ hasMentions: true,
158
+ };
159
+
160
+ const result = mapChannelFromBackend(backend);
161
+
162
+ expect(result.hasMentions).toBe(true);
163
+ });
164
+ });
165
+
166
+ describe('mapMessageFromBackend', () => {
167
+ it('should map basic message fields', () => {
168
+ const backend = {
169
+ id: 'msg-123',
170
+ channelId: 'ch-general',
171
+ from: 'alice',
172
+ fromEntityType: 'user',
173
+ content: 'Hello world!',
174
+ timestamp: '2024-01-01T12:00:00Z',
175
+ };
176
+
177
+ const result = mapMessageFromBackend(backend);
178
+
179
+ expect(result.id).toBe('msg-123');
180
+ expect(result.channelId).toBe('ch-general');
181
+ expect(result.from).toBe('alice');
182
+ expect(result.fromEntityType).toBe('user');
183
+ expect(result.content).toBe('Hello world!');
184
+ expect(result.timestamp).toBe('2024-01-01T12:00:00Z');
185
+ });
186
+
187
+ it('should handle agent messages', () => {
188
+ const backend = {
189
+ id: 'msg-456',
190
+ channelId: 'ch-dev',
191
+ senderName: 'CodeAgent',
192
+ fromEntityType: 'agent',
193
+ body: 'Task completed!',
194
+ createdAt: '2024-01-01T13:00:00Z',
195
+ };
196
+
197
+ const result = mapMessageFromBackend(backend);
198
+
199
+ expect(result.from).toBe('CodeAgent');
200
+ expect(result.fromEntityType).toBe('agent');
201
+ expect(result.content).toBe('Task completed!');
202
+ });
203
+
204
+ it('should handle pinned messages', () => {
205
+ const backend = {
206
+ id: 'msg-pinned',
207
+ channelId: 'ch-announcements',
208
+ from: 'admin',
209
+ content: 'Important announcement',
210
+ isPinned: true,
211
+ };
212
+
213
+ const result = mapMessageFromBackend(backend);
214
+
215
+ expect(result.isPinned).toBe(true);
216
+ });
217
+
218
+ it('should handle thread messages', () => {
219
+ const backend = {
220
+ id: 'msg-reply',
221
+ channelId: 'ch-general',
222
+ from: 'bob',
223
+ content: 'Reply in thread',
224
+ threadId: 'msg-parent',
225
+ };
226
+
227
+ const result = mapMessageFromBackend(backend);
228
+
229
+ expect(result.threadId).toBe('msg-parent');
230
+ });
231
+
232
+ it('should default isRead to true when not specified', () => {
233
+ const backend = {
234
+ id: 'msg-123',
235
+ channelId: 'ch-general',
236
+ from: 'alice',
237
+ content: 'Test',
238
+ };
239
+
240
+ const result = mapMessageFromBackend(backend);
241
+
242
+ expect(result.isRead).toBe(true);
243
+ });
244
+
245
+ it('should respect explicit isRead = false', () => {
246
+ const backend = {
247
+ id: 'msg-123',
248
+ channelId: 'ch-general',
249
+ from: 'alice',
250
+ content: 'Test',
251
+ isRead: false,
252
+ };
253
+
254
+ const result = mapMessageFromBackend(backend);
255
+
256
+ expect(result.isRead).toBe(false);
257
+ });
258
+ });
259
+
260
+ describe('mapSearchResultFromBackend', () => {
261
+ it('should map search result fields', () => {
262
+ const backend = {
263
+ id: 'msg-123',
264
+ channelId: 'ch-general',
265
+ channelName: 'general',
266
+ from: 'alice',
267
+ fromEntityType: 'user',
268
+ content: 'Hello world!',
269
+ snippet: '...Hello <b>world</b>!...',
270
+ timestamp: '2024-01-01T12:00:00Z',
271
+ rank: 0.95,
272
+ };
273
+
274
+ const result = mapSearchResultFromBackend(backend);
275
+
276
+ expect(result.id).toBe('msg-123');
277
+ expect(result.channelId).toBe('ch-general');
278
+ expect(result.channelName).toBe('general');
279
+ expect(result.from).toBe('alice');
280
+ expect(result.content).toBe('Hello world!');
281
+ expect(result.snippet).toBe('...Hello <b>world</b>!...');
282
+ expect(result.rank).toBe(0.95);
283
+ });
284
+
285
+ it('should handle alternative field names', () => {
286
+ const backend = {
287
+ messageId: 'msg-456',
288
+ channelId: 'ch-dev',
289
+ channelName: 'dev',
290
+ senderName: 'CodeAgent',
291
+ fromEntityType: 'agent',
292
+ body: 'Task update',
293
+ headline: 'Task <b>update</b>',
294
+ createdAt: '2024-01-02T10:00:00Z',
295
+ };
296
+
297
+ const result = mapSearchResultFromBackend(backend);
298
+
299
+ expect(result.id).toBe('msg-456');
300
+ expect(result.from).toBe('CodeAgent');
301
+ expect(result.content).toBe('Task update');
302
+ expect(result.snippet).toBe('Task <b>update</b>');
303
+ });
304
+
305
+ it('should use content as snippet fallback', () => {
306
+ const backend = {
307
+ id: 'msg-789',
308
+ channelId: 'ch-test',
309
+ channelName: 'test',
310
+ from: 'bob',
311
+ content: 'Plain text content',
312
+ };
313
+
314
+ const result = mapSearchResultFromBackend(backend);
315
+
316
+ expect(result.snippet).toBe('Plain text content');
317
+ });
318
+
319
+ it('should default rank to 0', () => {
320
+ const backend = {
321
+ id: 'msg-123',
322
+ channelId: 'ch-general',
323
+ channelName: 'general',
324
+ from: 'alice',
325
+ content: 'Test',
326
+ };
327
+
328
+ const result = mapSearchResultFromBackend(backend);
329
+
330
+ expect(result.rank).toBe(0);
331
+ });
332
+ });
333
+ });
334
+
335
+ describe('Channel API Integration', () => {
336
+ describe('View Mode State', () => {
337
+ it('should support channels view mode', () => {
338
+ type ViewMode = 'local' | 'fleet' | 'channels';
339
+ const viewMode: ViewMode = 'channels';
340
+ expect(viewMode).toBe('channels');
341
+ });
342
+
343
+ it('should allow switching between view modes', () => {
344
+ type ViewMode = 'local' | 'fleet' | 'channels';
345
+ let viewMode: ViewMode = 'local';
346
+
347
+ viewMode = 'channels';
348
+ expect(viewMode).toBe('channels');
349
+
350
+ viewMode = 'fleet';
351
+ expect(viewMode).toBe('fleet');
352
+
353
+ viewMode = 'local';
354
+ expect(viewMode).toBe('local');
355
+ });
356
+ });
357
+
358
+ describe('Channel Selection State', () => {
359
+ it('should find selected channel from list', () => {
360
+ const channels: Channel[] = [
361
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
362
+ { id: 'ch-2', name: 'random', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
363
+ ];
364
+ const selectedChannelId = 'ch-2';
365
+
366
+ const selectedChannel = channels.find(c => c.id === selectedChannelId);
367
+
368
+ expect(selectedChannel?.name).toBe('random');
369
+ });
370
+
371
+ it('should return undefined for non-existent channel', () => {
372
+ const channels: Channel[] = [
373
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
374
+ ];
375
+ const selectedChannelId = 'ch-999';
376
+
377
+ const selectedChannel = channels.find(c => c.id === selectedChannelId);
378
+
379
+ expect(selectedChannel).toBeUndefined();
380
+ });
381
+ });
382
+ });
383
+
384
+ describe('Command Palette Channel Commands', () => {
385
+ describe('Channel command generation', () => {
386
+ it('should generate Go to Channels command', () => {
387
+ const commands = [
388
+ {
389
+ id: 'channels-view',
390
+ label: 'Go to Channels',
391
+ description: 'Switch to channel messaging view',
392
+ category: 'channels' as const,
393
+ shortcut: '⌘⇧C',
394
+ },
395
+ ];
396
+
397
+ expect(commands[0].id).toBe('channels-view');
398
+ expect(commands[0].label).toBe('Go to Channels');
399
+ expect(commands[0].shortcut).toBe('⌘⇧C');
400
+ });
401
+
402
+ it('should generate Create Channel command', () => {
403
+ const commands = [
404
+ {
405
+ id: 'channels-create',
406
+ label: 'Create Channel',
407
+ description: 'Create a new messaging channel',
408
+ category: 'channels' as const,
409
+ },
410
+ ];
411
+
412
+ expect(commands[0].id).toBe('channels-create');
413
+ expect(commands[0].label).toBe('Create Channel');
414
+ });
415
+
416
+ it('should generate quick-switch commands for each channel', () => {
417
+ const channels: Channel[] = [
418
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false, description: 'Main channel' },
419
+ { id: 'ch-2', name: 'random', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 3, hasMentions: false, isDm: false },
420
+ ];
421
+
422
+ const commands = channels.map((channel) => ({
423
+ id: `channel-switch-${channel.id}`,
424
+ label: channel.isDm ? `@${channel.name}` : `#${channel.name}`,
425
+ description: channel.description || `Switch to ${channel.isDm ? 'DM' : 'channel'}`,
426
+ category: 'channels' as const,
427
+ }));
428
+
429
+ expect(commands).toHaveLength(2);
430
+ expect(commands[0].label).toBe('#general');
431
+ expect(commands[0].description).toBe('Main channel');
432
+ expect(commands[1].label).toBe('#random');
433
+ });
434
+
435
+ it('should format DM channels with @ prefix', () => {
436
+ const dmChannel: Channel = {
437
+ id: 'dm-1',
438
+ name: 'alice',
439
+ visibility: 'private',
440
+ status: 'active',
441
+ createdAt: '',
442
+ createdBy: '',
443
+ memberCount: 2,
444
+ unreadCount: 0,
445
+ hasMentions: false,
446
+ isDm: true,
447
+ dmParticipants: ['alice', 'bob'],
448
+ };
449
+
450
+ const label = dmChannel.isDm ? `@${dmChannel.name}` : `#${dmChannel.name}`;
451
+
452
+ expect(label).toBe('@alice');
453
+ });
454
+
455
+ it('should include unread count in channel description', () => {
456
+ const channel: Channel = {
457
+ id: 'ch-1',
458
+ name: 'alerts',
459
+ visibility: 'public',
460
+ status: 'active',
461
+ createdAt: '',
462
+ createdBy: '',
463
+ memberCount: 5,
464
+ unreadCount: 7,
465
+ hasMentions: false,
466
+ isDm: false,
467
+ };
468
+
469
+ const unreadBadge = channel.unreadCount > 0 ? ` (${channel.unreadCount} unread)` : '';
470
+ const description = channel.description || `Switch to channel${unreadBadge}`;
471
+
472
+ expect(description).toBe('Switch to channel (7 unread)');
473
+ });
474
+ });
475
+
476
+ describe('Keyboard shortcut handling', () => {
477
+ it('should detect Cmd+Shift+C for channels view', () => {
478
+ const event = {
479
+ metaKey: true,
480
+ ctrlKey: false,
481
+ shiftKey: true,
482
+ key: 'c',
483
+ };
484
+
485
+ const isChannelsShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'c';
486
+
487
+ expect(isChannelsShortcut).toBe(true);
488
+ });
489
+
490
+ it('should detect Ctrl+Shift+C for channels view (Windows/Linux)', () => {
491
+ const event = {
492
+ metaKey: false,
493
+ ctrlKey: true,
494
+ shiftKey: true,
495
+ key: 'c',
496
+ };
497
+
498
+ const isChannelsShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'c';
499
+
500
+ expect(isChannelsShortcut).toBe(true);
501
+ });
502
+
503
+ it('should not trigger for Cmd+C (without shift)', () => {
504
+ const event = {
505
+ metaKey: true,
506
+ ctrlKey: false,
507
+ shiftKey: false,
508
+ key: 'c',
509
+ };
510
+
511
+ const isChannelsShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'c';
512
+
513
+ expect(isChannelsShortcut).toBe(false);
514
+ });
515
+ });
516
+
517
+ describe('Client-side channel filtering', () => {
518
+ const channels: Channel[] = [
519
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false, description: 'General discussion' },
520
+ { id: 'ch-2', name: 'engineering', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false, description: 'Engineering team' },
521
+ { id: 'ch-3', name: 'marketing', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
522
+ { id: 'dm-1', name: 'alice', visibility: 'private', status: 'active', createdAt: '', createdBy: '', memberCount: 2, unreadCount: 0, hasMentions: false, isDm: true },
523
+ ];
524
+
525
+ it('should filter channels by name', () => {
526
+ const query = 'eng';
527
+ const filtered = channels.filter(c =>
528
+ c.name.toLowerCase().includes(query.toLowerCase())
529
+ );
530
+
531
+ expect(filtered).toHaveLength(1);
532
+ expect(filtered[0].name).toBe('engineering');
533
+ });
534
+
535
+ it('should filter channels by description', () => {
536
+ const query = 'team';
537
+ const filtered = channels.filter(c =>
538
+ c.name.toLowerCase().includes(query.toLowerCase()) ||
539
+ c.description?.toLowerCase().includes(query.toLowerCase())
540
+ );
541
+
542
+ expect(filtered).toHaveLength(1);
543
+ expect(filtered[0].name).toBe('engineering');
544
+ });
545
+
546
+ it('should return all channels with empty query', () => {
547
+ const query = '';
548
+ const filtered = query.trim()
549
+ ? channels.filter(c => c.name.toLowerCase().includes(query.toLowerCase()))
550
+ : channels;
551
+
552
+ expect(filtered).toHaveLength(4);
553
+ });
554
+
555
+ it('should return empty array when no matches', () => {
556
+ const query = 'xyz';
557
+ const filtered = channels.filter(c =>
558
+ c.name.toLowerCase().includes(query.toLowerCase())
559
+ );
560
+
561
+ expect(filtered).toHaveLength(0);
562
+ });
563
+
564
+ it('should filter case-insensitively', () => {
565
+ const query = 'GENERAL';
566
+ const filtered = channels.filter(c =>
567
+ c.name.toLowerCase().includes(query.toLowerCase())
568
+ );
569
+
570
+ expect(filtered).toHaveLength(1);
571
+ expect(filtered[0].name).toBe('general');
572
+ });
573
+ });
574
+ });
575
+
576
+ describe('Channel Message Pagination', () => {
577
+ describe('Cursor-based pagination', () => {
578
+ it('should use oldest message ID as cursor for loading more', () => {
579
+ const messages: ChannelMessage[] = [
580
+ { id: 'msg-1', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'First', timestamp: '2024-01-01T10:00:00Z', isPinned: false, isRead: true },
581
+ { id: 'msg-2', channelId: 'ch-1', from: 'bob', fromEntityType: 'user', content: 'Second', timestamp: '2024-01-01T11:00:00Z', isPinned: false, isRead: true },
582
+ { id: 'msg-3', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Third', timestamp: '2024-01-01T12:00:00Z', isPinned: false, isRead: true },
583
+ ];
584
+
585
+ const oldestMessage = messages[0];
586
+ const cursor = oldestMessage.id;
587
+
588
+ expect(cursor).toBe('msg-1');
589
+ });
590
+
591
+ it('should prepend older messages when loading more', () => {
592
+ const existingMessages: ChannelMessage[] = [
593
+ { id: 'msg-3', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Third', timestamp: '2024-01-01T12:00:00Z', isPinned: false, isRead: true },
594
+ ];
595
+
596
+ const olderMessages: ChannelMessage[] = [
597
+ { id: 'msg-1', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'First', timestamp: '2024-01-01T10:00:00Z', isPinned: false, isRead: true },
598
+ { id: 'msg-2', channelId: 'ch-1', from: 'bob', fromEntityType: 'user', content: 'Second', timestamp: '2024-01-01T11:00:00Z', isPinned: false, isRead: true },
599
+ ];
600
+
601
+ const combinedMessages = [...olderMessages, ...existingMessages];
602
+
603
+ expect(combinedMessages).toHaveLength(3);
604
+ expect(combinedMessages[0].id).toBe('msg-1');
605
+ expect(combinedMessages[2].id).toBe('msg-3');
606
+ });
607
+
608
+ it('should preserve message order after prepending', () => {
609
+ const existingMessages: ChannelMessage[] = [
610
+ { id: 'msg-50', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Msg 50', timestamp: '2024-01-01T14:00:00Z', isPinned: false, isRead: true },
611
+ { id: 'msg-51', channelId: 'ch-1', from: 'bob', fromEntityType: 'user', content: 'Msg 51', timestamp: '2024-01-01T15:00:00Z', isPinned: false, isRead: true },
612
+ ];
613
+
614
+ const olderMessages: ChannelMessage[] = [
615
+ { id: 'msg-48', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Msg 48', timestamp: '2024-01-01T12:00:00Z', isPinned: false, isRead: true },
616
+ { id: 'msg-49', channelId: 'ch-1', from: 'bob', fromEntityType: 'user', content: 'Msg 49', timestamp: '2024-01-01T13:00:00Z', isPinned: false, isRead: true },
617
+ ];
618
+
619
+ const combinedMessages = [...olderMessages, ...existingMessages];
620
+
621
+ // Verify chronological order
622
+ for (let i = 0; i < combinedMessages.length - 1; i++) {
623
+ const currentTime = new Date(combinedMessages[i].timestamp).getTime();
624
+ const nextTime = new Date(combinedMessages[i + 1].timestamp).getTime();
625
+ expect(currentTime).toBeLessThanOrEqual(nextTime);
626
+ }
627
+ });
628
+ });
629
+
630
+ describe('Pagination state management', () => {
631
+ it('should track hasMore state', () => {
632
+ interface PaginationState {
633
+ hasMore: boolean;
634
+ isLoading: boolean;
635
+ }
636
+
637
+ let state: PaginationState = { hasMore: false, isLoading: false };
638
+
639
+ // Initial load response indicates more messages exist
640
+ state = { ...state, hasMore: true };
641
+ expect(state.hasMore).toBe(true);
642
+
643
+ // After loading all messages
644
+ state = { ...state, hasMore: false };
645
+ expect(state.hasMore).toBe(false);
646
+ });
647
+
648
+ it('should prevent concurrent load-more requests', () => {
649
+ interface PaginationState {
650
+ hasMore: boolean;
651
+ isLoadingMore: boolean;
652
+ }
653
+
654
+ const state: PaginationState = { hasMore: true, isLoadingMore: true };
655
+
656
+ // Should not trigger another load while loading
657
+ const canLoadMore = state.hasMore && !state.isLoadingMore;
658
+ expect(canLoadMore).toBe(false);
659
+ });
660
+
661
+ it('should not load more when no more messages', () => {
662
+ interface PaginationState {
663
+ hasMore: boolean;
664
+ isLoadingMore: boolean;
665
+ }
666
+
667
+ const state: PaginationState = { hasMore: false, isLoadingMore: false };
668
+
669
+ const canLoadMore = state.hasMore && !state.isLoadingMore;
670
+ expect(canLoadMore).toBe(false);
671
+ });
672
+
673
+ it('should allow loading when hasMore is true and not loading', () => {
674
+ interface PaginationState {
675
+ hasMore: boolean;
676
+ isLoadingMore: boolean;
677
+ }
678
+
679
+ const state: PaginationState = { hasMore: true, isLoadingMore: false };
680
+
681
+ const canLoadMore = state.hasMore && !state.isLoadingMore;
682
+ expect(canLoadMore).toBe(true);
683
+ });
684
+ });
685
+
686
+ describe('Empty result handling', () => {
687
+ it('should handle empty older messages response', () => {
688
+ const existingMessages: ChannelMessage[] = [
689
+ { id: 'msg-1', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'First', timestamp: '2024-01-01T10:00:00Z', isPinned: false, isRead: true },
690
+ ];
691
+
692
+ const olderMessages: ChannelMessage[] = [];
693
+
694
+ const combinedMessages = [...olderMessages, ...existingMessages];
695
+
696
+ expect(combinedMessages).toHaveLength(1);
697
+ expect(combinedMessages[0].id).toBe('msg-1');
698
+ });
699
+
700
+ it('should reset hasMore on empty response', () => {
701
+ const responseHasMore = false;
702
+ expect(responseHasMore).toBe(false);
703
+ });
704
+ });
705
+
706
+ describe('Limit enforcement', () => {
707
+ it('should respect max limit of 100', () => {
708
+ const requestedLimit = 150;
709
+ const enforcedLimit = Math.min(requestedLimit, 100);
710
+ expect(enforcedLimit).toBe(100);
711
+ });
712
+
713
+ it('should use default limit when not specified', () => {
714
+ const defaultLimit = 50;
715
+ expect(defaultLimit).toBe(50);
716
+ });
717
+ });
718
+ });
719
+
720
+ // =============================================================================
721
+ // Task 9b: Unread UI Tests
722
+ // =============================================================================
723
+
724
+ describe('Unread UI', () => {
725
+ describe('Unread state tracking', () => {
726
+ it('should track unread count from API response', () => {
727
+ interface UnreadState {
728
+ count: number;
729
+ firstUnreadMessageId?: string;
730
+ }
731
+
732
+ const apiResponse = {
733
+ messages: [],
734
+ hasMore: false,
735
+ unread: { count: 5, firstUnreadMessageId: 'msg-123' },
736
+ };
737
+
738
+ const unreadState: UnreadState = apiResponse.unread;
739
+
740
+ expect(unreadState.count).toBe(5);
741
+ expect(unreadState.firstUnreadMessageId).toBe('msg-123');
742
+ });
743
+
744
+ it('should handle zero unread messages', () => {
745
+ interface UnreadState {
746
+ count: number;
747
+ firstUnreadMessageId?: string;
748
+ }
749
+
750
+ const apiResponse = {
751
+ messages: [],
752
+ hasMore: false,
753
+ unread: { count: 0 },
754
+ };
755
+
756
+ const unreadState: UnreadState = apiResponse.unread;
757
+
758
+ expect(unreadState.count).toBe(0);
759
+ expect(unreadState.firstUnreadMessageId).toBeUndefined();
760
+ });
761
+
762
+ it('should update unread state when channel changes', () => {
763
+ interface ChannelUnreadState {
764
+ [channelId: string]: { count: number; firstUnreadMessageId?: string };
765
+ }
766
+
767
+ const unreadStates: ChannelUnreadState = {
768
+ 'ch-1': { count: 3, firstUnreadMessageId: 'msg-10' },
769
+ 'ch-2': { count: 0 },
770
+ };
771
+
772
+ expect(unreadStates['ch-1'].count).toBe(3);
773
+ expect(unreadStates['ch-2'].count).toBe(0);
774
+ });
775
+ });
776
+
777
+ describe('Unread separator display', () => {
778
+ it('should identify first unread message for separator', () => {
779
+ const _messages: ChannelMessage[] = [
780
+ { id: 'msg-1', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Read message', timestamp: '2024-01-01T10:00:00Z', isPinned: false, isRead: true },
781
+ { id: 'msg-2', channelId: 'ch-1', from: 'bob', fromEntityType: 'user', content: 'First unread', timestamp: '2024-01-01T11:00:00Z', isPinned: false, isRead: false },
782
+ { id: 'msg-3', channelId: 'ch-1', from: 'alice', fromEntityType: 'user', content: 'Second unread', timestamp: '2024-01-01T12:00:00Z', isPinned: false, isRead: false },
783
+ ];
784
+
785
+ const unreadState = { count: 2, firstUnreadMessageId: 'msg-2' };
786
+
787
+ const shouldShowSeparator = (messageId: string) =>
788
+ messageId === unreadState.firstUnreadMessageId && unreadState.count > 0;
789
+
790
+ expect(shouldShowSeparator('msg-1')).toBe(false);
791
+ expect(shouldShowSeparator('msg-2')).toBe(true);
792
+ expect(shouldShowSeparator('msg-3')).toBe(false);
793
+ });
794
+
795
+ it('should not show separator when all messages are read', () => {
796
+ const unreadState = { count: 0 };
797
+
798
+ const shouldShowSeparator = (messageId: string) =>
799
+ messageId === unreadState.firstUnreadMessageId && unreadState.count > 0;
800
+
801
+ expect(shouldShowSeparator('msg-1')).toBe(false);
802
+ });
803
+ });
804
+
805
+ describe('Mark as read behavior', () => {
806
+ it('should call markRead when viewing channel', () => {
807
+ let markReadCalled = false;
808
+ let markedChannelId: string | null = null;
809
+
810
+ const mockMarkRead = (workspaceId: string, channelId: string) => {
811
+ markReadCalled = true;
812
+ markedChannelId = channelId;
813
+ };
814
+
815
+ // Simulate viewing a channel
816
+ mockMarkRead('ws-1', 'ch-general');
817
+
818
+ expect(markReadCalled).toBe(true);
819
+ expect(markedChannelId).toBe('ch-general');
820
+ });
821
+
822
+ it('should update local unread count after marking read', () => {
823
+ const channels: Channel[] = [
824
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 5, hasMentions: false, isDm: false },
825
+ ];
826
+
827
+ // After marking read, update local state
828
+ const updatedChannels = channels.map(c =>
829
+ c.id === 'ch-1' ? { ...c, unreadCount: 0 } : c
830
+ );
831
+
832
+ expect(updatedChannels[0].unreadCount).toBe(0);
833
+ });
834
+
835
+ it('should debounce markRead calls to prevent spam', async () => {
836
+ let callCount = 0;
837
+
838
+ const debouncedMarkRead = (() => {
839
+ let timeout: NodeJS.Timeout | null = null;
840
+ return () => {
841
+ if (timeout) clearTimeout(timeout);
842
+ timeout = setTimeout(() => {
843
+ callCount++;
844
+ }, 100);
845
+ };
846
+ })();
847
+
848
+ // Rapid calls
849
+ debouncedMarkRead();
850
+ debouncedMarkRead();
851
+ debouncedMarkRead();
852
+
853
+ // Wait for debounce
854
+ await new Promise(resolve => setTimeout(resolve, 150));
855
+
856
+ expect(callCount).toBe(1);
857
+ });
858
+ });
859
+
860
+ describe('Sidebar unread badges', () => {
861
+ it('should show unread badge when count > 0', () => {
862
+ const channel: Channel = {
863
+ id: 'ch-1', name: 'alerts', visibility: 'public', status: 'active',
864
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 7,
865
+ hasMentions: false, isDm: false,
866
+ };
867
+
868
+ const showBadge = channel.unreadCount > 0;
869
+ expect(showBadge).toBe(true);
870
+ });
871
+
872
+ it('should not show badge when count is 0', () => {
873
+ const channel: Channel = {
874
+ id: 'ch-1', name: 'general', visibility: 'public', status: 'active',
875
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
876
+ hasMentions: false, isDm: false,
877
+ };
878
+
879
+ const showBadge = channel.unreadCount > 0;
880
+ expect(showBadge).toBe(false);
881
+ });
882
+
883
+ it('should cap badge display at 99+', () => {
884
+ const channel: Channel = {
885
+ id: 'ch-1', name: 'busy', visibility: 'public', status: 'active',
886
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 150,
887
+ hasMentions: false, isDm: false,
888
+ };
889
+
890
+ const badgeText = channel.unreadCount > 99 ? '99+' : String(channel.unreadCount);
891
+ expect(badgeText).toBe('99+');
892
+ });
893
+
894
+ it('should calculate total unread count across channels', () => {
895
+ const channels: Channel[] = [
896
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 3, hasMentions: false, isDm: false },
897
+ { id: 'ch-2', name: 'random', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 5, hasMentions: false, isDm: false },
898
+ { id: 'ch-3', name: 'dev', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
899
+ ];
900
+
901
+ const totalUnread = channels.reduce((sum, c) => sum + c.unreadCount, 0);
902
+ expect(totalUnread).toBe(8);
903
+ });
904
+ });
905
+
906
+ describe('Unread state with mentions', () => {
907
+ it('should show mention indicator when hasMentions is true', () => {
908
+ const channel: Channel = {
909
+ id: 'ch-1', name: 'alerts', visibility: 'public', status: 'active',
910
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 2,
911
+ hasMentions: true, isDm: false,
912
+ };
913
+
914
+ expect(channel.hasMentions).toBe(true);
915
+ });
916
+
917
+ it('should prioritize mention styling over regular unread', () => {
918
+ const channel: Channel = {
919
+ id: 'ch-1', name: 'alerts', visibility: 'public', status: 'active',
920
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 2,
921
+ hasMentions: true, isDm: false,
922
+ };
923
+
924
+ // Mention takes priority for styling
925
+ const badgeStyle = channel.hasMentions ? 'mention' : (channel.unreadCount > 0 ? 'unread' : 'none');
926
+ expect(badgeStyle).toBe('mention');
927
+ });
928
+ });
929
+ });
930
+
931
+ // =============================================================================
932
+ // Task 9b: Archive Tests
933
+ // =============================================================================
934
+
935
+ describe('Archive Functionality', () => {
936
+ describe('Archive channel workflow', () => {
937
+ it('should move channel to archived list on archive', () => {
938
+ let channels: Channel[] = [
939
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
940
+ { id: 'ch-2', name: 'old-project', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
941
+ ];
942
+ let archivedChannels: Channel[] = [];
943
+
944
+ // Archive ch-2
945
+ const channelToArchive = channels.find(c => c.id === 'ch-2')!;
946
+ const archivedChannel = { ...channelToArchive, status: 'archived' as const };
947
+
948
+ channels = channels.filter(c => c.id !== 'ch-2');
949
+ archivedChannels = [...archivedChannels, archivedChannel];
950
+
951
+ expect(channels).toHaveLength(1);
952
+ expect(archivedChannels).toHaveLength(1);
953
+ expect(archivedChannels[0].status).toBe('archived');
954
+ });
955
+
956
+ it('should update channel status to archived', () => {
957
+ const channel: Channel = {
958
+ id: 'ch-1', name: 'old-project', visibility: 'public', status: 'active',
959
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
960
+ hasMentions: false, isDm: false,
961
+ };
962
+
963
+ const archivedChannel = { ...channel, status: 'archived' as const };
964
+
965
+ expect(archivedChannel.status).toBe('archived');
966
+ });
967
+
968
+ it('should clear selection if archived channel was selected', () => {
969
+ let selectedChannelId: string | null = 'ch-archived';
970
+ const archivedChannelIds = ['ch-archived'];
971
+
972
+ // On archive, clear selection if it was the archived channel
973
+ if (selectedChannelId && archivedChannelIds.includes(selectedChannelId)) {
974
+ selectedChannelId = null;
975
+ }
976
+
977
+ expect(selectedChannelId).toBeNull();
978
+ });
979
+ });
980
+
981
+ describe('Unarchive channel workflow', () => {
982
+ it('should move channel back to active list on unarchive', () => {
983
+ let channels: Channel[] = [
984
+ { id: 'ch-1', name: 'general', visibility: 'public', status: 'active', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
985
+ ];
986
+ let archivedChannels: Channel[] = [
987
+ { id: 'ch-2', name: 'old-project', visibility: 'public', status: 'archived', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
988
+ ];
989
+
990
+ // Unarchive ch-2
991
+ const channelToUnarchive = archivedChannels.find(c => c.id === 'ch-2')!;
992
+ const unarchivedChannel = { ...channelToUnarchive, status: 'active' as const };
993
+
994
+ archivedChannels = archivedChannels.filter(c => c.id !== 'ch-2');
995
+ channels = [...channels, unarchivedChannel];
996
+
997
+ expect(channels).toHaveLength(2);
998
+ expect(archivedChannels).toHaveLength(0);
999
+ expect(channels[1].status).toBe('active');
1000
+ });
1001
+ });
1002
+
1003
+ describe('Archived section display', () => {
1004
+ it('should show archived section when archived channels exist', () => {
1005
+ const archivedChannels: Channel[] = [
1006
+ { id: 'ch-archived', name: 'old-project', visibility: 'public', status: 'archived', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
1007
+ ];
1008
+
1009
+ const showArchivedSection = archivedChannels.length > 0;
1010
+ expect(showArchivedSection).toBe(true);
1011
+ });
1012
+
1013
+ it('should hide archived section when no archived channels', () => {
1014
+ const archivedChannels: Channel[] = [];
1015
+
1016
+ const showArchivedSection = archivedChannels.length > 0;
1017
+ expect(showArchivedSection).toBe(false);
1018
+ });
1019
+
1020
+ it('should persist collapsed state in localStorage', () => {
1021
+ const STORAGE_KEY = 'channels-v1-archived-collapsed';
1022
+
1023
+ // Simulate localStorage
1024
+ const storage: Record<string, string> = {};
1025
+
1026
+ // Save collapsed state
1027
+ storage[STORAGE_KEY] = 'true';
1028
+ expect(storage[STORAGE_KEY]).toBe('true');
1029
+
1030
+ // Load collapsed state
1031
+ const isCollapsed = storage[STORAGE_KEY] === 'true';
1032
+ expect(isCollapsed).toBe(true);
1033
+ });
1034
+
1035
+ it('should filter archived channels by search query', () => {
1036
+ const archivedChannels: Channel[] = [
1037
+ { id: 'ch-1', name: 'old-project-alpha', visibility: 'public', status: 'archived', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
1038
+ { id: 'ch-2', name: 'old-project-beta', visibility: 'public', status: 'archived', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
1039
+ { id: 'ch-3', name: 'archive-misc', visibility: 'public', status: 'archived', createdAt: '', createdBy: '', memberCount: 0, unreadCount: 0, hasMentions: false, isDm: false },
1040
+ ];
1041
+
1042
+ const searchQuery = 'project';
1043
+ const filtered = archivedChannels.filter(c =>
1044
+ c.name.toLowerCase().includes(searchQuery.toLowerCase())
1045
+ );
1046
+
1047
+ expect(filtered).toHaveLength(2);
1048
+ });
1049
+ });
1050
+
1051
+ describe('Archived channel behavior', () => {
1052
+ it('should disable message input for archived channels', () => {
1053
+ const channel: Channel = {
1054
+ id: 'ch-1', name: 'archived-channel', visibility: 'public', status: 'archived',
1055
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1056
+ hasMentions: false, isDm: false,
1057
+ };
1058
+
1059
+ const isArchived = channel.status === 'archived';
1060
+ const inputDisabled = isArchived;
1061
+
1062
+ expect(inputDisabled).toBe(true);
1063
+ });
1064
+
1065
+ it('should show archived indicator in channel header', () => {
1066
+ const channel: Channel = {
1067
+ id: 'ch-1', name: 'archived-channel', visibility: 'public', status: 'archived',
1068
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1069
+ hasMentions: false, isDm: false,
1070
+ };
1071
+
1072
+ const showArchivedIndicator = channel.status === 'archived';
1073
+ expect(showArchivedIndicator).toBe(true);
1074
+ });
1075
+
1076
+ it('should allow viewing messages in archived channel', () => {
1077
+ const _channel: Channel = {
1078
+ id: 'ch-1', name: 'archived-channel', visibility: 'public', status: 'archived',
1079
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1080
+ hasMentions: false, isDm: false,
1081
+ };
1082
+
1083
+ // Archived channels can still be viewed
1084
+ const canViewMessages = true; // Always true
1085
+ expect(canViewMessages).toBe(true);
1086
+ });
1087
+
1088
+ it('should not show archive option for already archived channels', () => {
1089
+ const channel: Channel = {
1090
+ id: 'ch-1', name: 'archived-channel', visibility: 'public', status: 'archived',
1091
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1092
+ hasMentions: false, isDm: false,
1093
+ };
1094
+
1095
+ const isArchived = channel.status === 'archived';
1096
+ const showArchiveOption = !isArchived;
1097
+ const showUnarchiveOption = isArchived;
1098
+
1099
+ expect(showArchiveOption).toBe(false);
1100
+ expect(showUnarchiveOption).toBe(true);
1101
+ });
1102
+ });
1103
+
1104
+ describe('Archive confirmation dialog', () => {
1105
+ it('should require confirmation before archiving', () => {
1106
+ let confirmationShown = false;
1107
+ let channelToArchive: Channel | null = null;
1108
+
1109
+ const requestArchive = (channel: Channel) => {
1110
+ confirmationShown = true;
1111
+ channelToArchive = channel;
1112
+ };
1113
+
1114
+ requestArchive({
1115
+ id: 'ch-1', name: 'important-channel', visibility: 'public', status: 'active',
1116
+ createdAt: '', createdBy: '', memberCount: 10, unreadCount: 0,
1117
+ hasMentions: false, isDm: false,
1118
+ });
1119
+
1120
+ expect(confirmationShown).toBe(true);
1121
+ expect(channelToArchive?.name).toBe('important-channel');
1122
+ });
1123
+
1124
+ it('should show different text for archive vs unarchive', () => {
1125
+ const getDialogTitle = (isUnarchiving: boolean) =>
1126
+ isUnarchiving ? 'Unarchive channel?' : 'Archive channel?';
1127
+
1128
+ expect(getDialogTitle(false)).toBe('Archive channel?');
1129
+ expect(getDialogTitle(true)).toBe('Unarchive channel?');
1130
+ });
1131
+ });
1132
+
1133
+ describe('Archive API calls', () => {
1134
+ it('should construct correct archive URL', () => {
1135
+ const workspaceId = 'ws-123';
1136
+ const channelId = 'ch-456';
1137
+
1138
+ const archiveUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/archive`;
1139
+
1140
+ expect(archiveUrl).toBe('/api/workspaces/ws-123/channels/ch-456/archive');
1141
+ });
1142
+
1143
+ it('should construct correct unarchive URL', () => {
1144
+ const workspaceId = 'ws-123';
1145
+ const channelId = 'ch-456';
1146
+
1147
+ const unarchiveUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/unarchive`;
1148
+
1149
+ expect(unarchiveUrl).toBe('/api/workspaces/ws-123/channels/ch-456/unarchive');
1150
+ });
1151
+
1152
+ it('should encode special characters in channel ID', () => {
1153
+ const workspaceId = 'ws-123';
1154
+ const channelId = 'channel/with/slashes';
1155
+
1156
+ const archiveUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/archive`;
1157
+
1158
+ expect(archiveUrl).toBe('/api/workspaces/ws-123/channels/channel%2Fwith%2Fslashes/archive');
1159
+ });
1160
+ });
1161
+ });
1162
+
1163
+ // =============================================================================
1164
+ // Task 10: Admin Tools Tests
1165
+ // =============================================================================
1166
+
1167
+ describe('Admin Tools', () => {
1168
+ describe('Channel Settings API', () => {
1169
+ it('should construct correct updateChannel URL', () => {
1170
+ const workspaceId = 'ws-123';
1171
+ const channelId = 'ch-456';
1172
+
1173
+ const updateUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}`;
1174
+
1175
+ expect(updateUrl).toBe('/api/workspaces/ws-123/channels/ch-456');
1176
+ });
1177
+
1178
+ it('should build PATCH request body for name update', () => {
1179
+ const updates = { name: 'new-channel-name' };
1180
+ const body = JSON.stringify(updates);
1181
+
1182
+ expect(JSON.parse(body)).toEqual({ name: 'new-channel-name' });
1183
+ });
1184
+
1185
+ it('should build PATCH request body for description update', () => {
1186
+ const updates = { description: 'New channel description' };
1187
+ const body = JSON.stringify(updates);
1188
+
1189
+ expect(JSON.parse(body)).toEqual({ description: 'New channel description' });
1190
+ });
1191
+
1192
+ it('should build PATCH request body for visibility update', () => {
1193
+ const updates = { isPrivate: true };
1194
+ const body = JSON.stringify(updates);
1195
+
1196
+ expect(JSON.parse(body)).toEqual({ isPrivate: true });
1197
+ });
1198
+
1199
+ it('should support partial updates (multiple fields)', () => {
1200
+ const updates = {
1201
+ name: 'renamed-channel',
1202
+ description: 'Updated description',
1203
+ isPrivate: false,
1204
+ };
1205
+
1206
+ expect(updates.name).toBe('renamed-channel');
1207
+ expect(updates.description).toBe('Updated description');
1208
+ expect(updates.isPrivate).toBe(false);
1209
+ });
1210
+
1211
+ it('should validate channel name format', () => {
1212
+ const validateChannelName = (name: string) => {
1213
+ const normalized = name.trim().toLowerCase().replace(/\s+/g, '-');
1214
+ return /^[a-z0-9-]+$/.test(normalized) && normalized.length >= 2 && normalized.length <= 80;
1215
+ };
1216
+
1217
+ expect(validateChannelName('general')).toBe(true);
1218
+ expect(validateChannelName('my-channel')).toBe(true);
1219
+ expect(validateChannelName('channel123')).toBe(true);
1220
+ expect(validateChannelName('a')).toBe(false); // Too short
1221
+ expect(validateChannelName('Invalid Name!')).toBe(false); // Invalid chars
1222
+ expect(validateChannelName('')).toBe(false); // Empty
1223
+ });
1224
+ });
1225
+
1226
+ describe('Member Management API', () => {
1227
+ it('should construct correct addMember URL', () => {
1228
+ const workspaceId = 'ws-123';
1229
+ const channelId = 'ch-456';
1230
+
1231
+ const addMemberUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/members`;
1232
+
1233
+ expect(addMemberUrl).toBe('/api/workspaces/ws-123/channels/ch-456/members');
1234
+ });
1235
+
1236
+ it('should construct correct removeMember URL', () => {
1237
+ const workspaceId = 'ws-123';
1238
+ const channelId = 'ch-456';
1239
+ const memberId = 'user-789';
1240
+
1241
+ const removeMemberUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/members/${encodeURIComponent(memberId)}`;
1242
+
1243
+ expect(removeMemberUrl).toBe('/api/workspaces/ws-123/channels/ch-456/members/user-789');
1244
+ });
1245
+
1246
+ it('should build POST request body for adding user member', () => {
1247
+ const request = { userId: 'user-123', role: 'member' };
1248
+ const body = JSON.stringify(request);
1249
+
1250
+ expect(JSON.parse(body)).toEqual({ userId: 'user-123', role: 'member' });
1251
+ });
1252
+
1253
+ it('should build POST request body for adding agent member', () => {
1254
+ const request = { agentName: 'CodeAgent', role: 'member' };
1255
+ const body = JSON.stringify(request);
1256
+
1257
+ expect(JSON.parse(body)).toEqual({ agentName: 'CodeAgent', role: 'member' });
1258
+ });
1259
+
1260
+ it('should construct updateMemberRole URL', () => {
1261
+ const workspaceId = 'ws-123';
1262
+ const channelId = 'ch-456';
1263
+ const memberId = 'user-789';
1264
+
1265
+ const updateRoleUrl = `/api/workspaces/${workspaceId}/channels/${encodeURIComponent(channelId)}/members/${encodeURIComponent(memberId)}/role`;
1266
+
1267
+ expect(updateRoleUrl).toBe('/api/workspaces/ws-123/channels/ch-456/members/user-789/role');
1268
+ });
1269
+
1270
+ it('should build PATCH request body for role update', () => {
1271
+ const request = { role: 'admin' };
1272
+ const body = JSON.stringify(request);
1273
+
1274
+ expect(JSON.parse(body)).toEqual({ role: 'admin' });
1275
+ });
1276
+ });
1277
+
1278
+ describe('Member Management State', () => {
1279
+ it('should add member to local list', () => {
1280
+ const members: ChannelMember[] = [
1281
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1282
+ ];
1283
+
1284
+ const newMember: ChannelMember = {
1285
+ id: 'user-2',
1286
+ displayName: 'Bob',
1287
+ entityType: 'user',
1288
+ role: 'member',
1289
+ status: 'offline',
1290
+ joinedAt: '2024-01-02',
1291
+ };
1292
+
1293
+ const updatedMembers = [...members, newMember];
1294
+
1295
+ expect(updatedMembers).toHaveLength(2);
1296
+ expect(updatedMembers[1].displayName).toBe('Bob');
1297
+ });
1298
+
1299
+ it('should remove member from local list', () => {
1300
+ const members: ChannelMember[] = [
1301
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1302
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'member', status: 'offline', joinedAt: '2024-01-02' },
1303
+ ];
1304
+
1305
+ const memberIdToRemove = 'user-2';
1306
+ const updatedMembers = members.filter(m => m.id !== memberIdToRemove);
1307
+
1308
+ expect(updatedMembers).toHaveLength(1);
1309
+ expect(updatedMembers[0].displayName).toBe('Alice');
1310
+ });
1311
+
1312
+ it('should update member role in local list', () => {
1313
+ const members: ChannelMember[] = [
1314
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1315
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'member', status: 'offline', joinedAt: '2024-01-02' },
1316
+ ];
1317
+
1318
+ const memberIdToUpdate = 'user-2';
1319
+ const newRole = 'admin' as const;
1320
+ const updatedMembers = members.map(m =>
1321
+ m.id === memberIdToUpdate ? { ...m, role: newRole } : m
1322
+ );
1323
+
1324
+ expect(updatedMembers[1].role).toBe('admin');
1325
+ });
1326
+
1327
+ it('should update channel member count after adding', () => {
1328
+ const channel: Channel = {
1329
+ id: 'ch-1', name: 'general', visibility: 'public', status: 'active',
1330
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1331
+ hasMentions: false, isDm: false,
1332
+ };
1333
+
1334
+ const updatedChannel = { ...channel, memberCount: channel.memberCount + 1 };
1335
+
1336
+ expect(updatedChannel.memberCount).toBe(6);
1337
+ });
1338
+
1339
+ it('should update channel member count after removing', () => {
1340
+ const channel: Channel = {
1341
+ id: 'ch-1', name: 'general', visibility: 'public', status: 'active',
1342
+ createdAt: '', createdBy: '', memberCount: 5, unreadCount: 0,
1343
+ hasMentions: false, isDm: false,
1344
+ };
1345
+
1346
+ const updatedChannel = { ...channel, memberCount: Math.max(0, channel.memberCount - 1) };
1347
+
1348
+ expect(updatedChannel.memberCount).toBe(4);
1349
+ });
1350
+ });
1351
+
1352
+ describe('Agent Assignment', () => {
1353
+ it('should identify agent members', () => {
1354
+ const members: ChannelMember[] = [
1355
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1356
+ { id: 'agent-1', displayName: 'CodeAgent', entityType: 'agent', role: 'member', status: 'online', joinedAt: '2024-01-02' },
1357
+ { id: 'agent-2', displayName: 'ReviewAgent', entityType: 'agent', role: 'member', status: 'offline', joinedAt: '2024-01-03' },
1358
+ ];
1359
+
1360
+ const agentMembers = members.filter(m => m.entityType === 'agent');
1361
+
1362
+ expect(agentMembers).toHaveLength(2);
1363
+ expect(agentMembers[0].displayName).toBe('CodeAgent');
1364
+ });
1365
+
1366
+ it('should identify user members', () => {
1367
+ const members: ChannelMember[] = [
1368
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1369
+ { id: 'agent-1', displayName: 'CodeAgent', entityType: 'agent', role: 'member', status: 'online', joinedAt: '2024-01-02' },
1370
+ ];
1371
+
1372
+ const userMembers = members.filter(m => m.entityType === 'user');
1373
+
1374
+ expect(userMembers).toHaveLength(1);
1375
+ expect(userMembers[0].displayName).toBe('Alice');
1376
+ });
1377
+
1378
+ it('should build request for assigning agent to channel', () => {
1379
+ const agentName = 'CodeReviewAgent';
1380
+ const request = { agentName, role: 'member' };
1381
+
1382
+ expect(request.agentName).toBe('CodeReviewAgent');
1383
+ expect(request.role).toBe('member');
1384
+ });
1385
+ });
1386
+
1387
+ describe('Permission Checks', () => {
1388
+ it('should identify channel owner', () => {
1389
+ const members: ChannelMember[] = [
1390
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1391
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'member', status: 'offline', joinedAt: '2024-01-02' },
1392
+ ];
1393
+
1394
+ const owner = members.find(m => m.role === 'owner');
1395
+
1396
+ expect(owner?.displayName).toBe('Alice');
1397
+ });
1398
+
1399
+ it('should check if user can edit channel (owner only)', () => {
1400
+ const currentUserId = 'user-1';
1401
+ const members: ChannelMember[] = [
1402
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1403
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'member', status: 'offline', joinedAt: '2024-01-02' },
1404
+ ];
1405
+
1406
+ const currentMember = members.find(m => m.id === currentUserId);
1407
+ const canEdit = currentMember?.role === 'owner' || currentMember?.role === 'admin';
1408
+
1409
+ expect(canEdit).toBe(true);
1410
+ });
1411
+
1412
+ it('should check if user can remove members (owner/admin)', () => {
1413
+ const currentUserId = 'user-2';
1414
+ const members: ChannelMember[] = [
1415
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1416
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'admin', status: 'offline', joinedAt: '2024-01-02' },
1417
+ ];
1418
+
1419
+ const currentMember = members.find(m => m.id === currentUserId);
1420
+ const canRemoveMembers = currentMember?.role === 'owner' || currentMember?.role === 'admin';
1421
+
1422
+ expect(canRemoveMembers).toBe(true);
1423
+ });
1424
+
1425
+ it('should prevent non-admin from editing channel', () => {
1426
+ const currentUserId = 'user-2';
1427
+ const members: ChannelMember[] = [
1428
+ { id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01' },
1429
+ { id: 'user-2', displayName: 'Bob', entityType: 'user', role: 'member', status: 'offline', joinedAt: '2024-01-02' },
1430
+ ];
1431
+
1432
+ const currentMember = members.find(m => m.id === currentUserId);
1433
+ const canEdit = currentMember?.role === 'owner' || currentMember?.role === 'admin';
1434
+
1435
+ expect(canEdit).toBe(false);
1436
+ });
1437
+
1438
+ it('should prevent removing channel owner', () => {
1439
+ const memberToRemove: ChannelMember = {
1440
+ id: 'user-1', displayName: 'Alice', entityType: 'user', role: 'owner', status: 'online', joinedAt: '2024-01-01',
1441
+ };
1442
+
1443
+ const canRemove = memberToRemove.role !== 'owner';
1444
+
1445
+ expect(canRemove).toBe(false);
1446
+ });
1447
+ });
1448
+
1449
+ describe('Channel Settings Modal State', () => {
1450
+ it('should initialize form with current channel values', () => {
1451
+ const channel: Channel = {
1452
+ id: 'ch-1', name: 'engineering', description: 'Engineering team channel',
1453
+ visibility: 'private', status: 'active', createdAt: '', createdBy: '',
1454
+ memberCount: 10, unreadCount: 0, hasMentions: false, isDm: false,
1455
+ };
1456
+
1457
+ const formState = {
1458
+ name: channel.name,
1459
+ description: channel.description || '',
1460
+ isPrivate: channel.visibility === 'private',
1461
+ };
1462
+
1463
+ expect(formState.name).toBe('engineering');
1464
+ expect(formState.description).toBe('Engineering team channel');
1465
+ expect(formState.isPrivate).toBe(true);
1466
+ });
1467
+
1468
+ it('should detect if form has changes', () => {
1469
+ const original = { name: 'general', description: 'Main channel', isPrivate: false };
1470
+ const current = { name: 'general-renamed', description: 'Main channel', isPrivate: false };
1471
+
1472
+ const hasChanges = original.name !== current.name ||
1473
+ original.description !== current.description ||
1474
+ original.isPrivate !== current.isPrivate;
1475
+
1476
+ expect(hasChanges).toBe(true);
1477
+ });
1478
+
1479
+ it('should track which fields changed for partial update', () => {
1480
+ const original = { name: 'general', description: 'Main channel', isPrivate: false };
1481
+ const current = { name: 'general', description: 'Updated description', isPrivate: true };
1482
+
1483
+ const changes: Record<string, unknown> = {};
1484
+ if (original.name !== current.name) changes.name = current.name;
1485
+ if (original.description !== current.description) changes.description = current.description;
1486
+ if (original.isPrivate !== current.isPrivate) changes.isPrivate = current.isPrivate;
1487
+
1488
+ expect(changes).toEqual({ description: 'Updated description', isPrivate: true });
1489
+ });
1490
+ });
1491
+
1492
+ describe('Error Handling', () => {
1493
+ it('should handle 403 forbidden for non-admin operations', () => {
1494
+ const errorCode = 403;
1495
+ const errorMessage = 'Admin access required';
1496
+
1497
+ const isPermissionError = errorCode === 403;
1498
+ expect(isPermissionError).toBe(true);
1499
+ expect(errorMessage).toContain('Admin');
1500
+ });
1501
+
1502
+ it('should handle 404 for non-existent member', () => {
1503
+ const errorCode = 404;
1504
+ const _errorMessage = 'Member not found';
1505
+
1506
+ const isNotFoundError = errorCode === 404;
1507
+ expect(isNotFoundError).toBe(true);
1508
+ });
1509
+
1510
+ it('should handle 409 conflict for duplicate member', () => {
1511
+ const errorCode = 409;
1512
+ const _errorMessage = 'Member already exists in channel';
1513
+
1514
+ const isConflictError = errorCode === 409;
1515
+ expect(isConflictError).toBe(true);
1516
+ });
1517
+
1518
+ it('should handle removing self from channel', () => {
1519
+ const currentUserId = 'user-1';
1520
+ const memberToRemove = { id: 'user-1', displayName: 'Me' };
1521
+
1522
+ const isRemovingSelf = currentUserId === memberToRemove.id;
1523
+ expect(isRemovingSelf).toBe(true);
1524
+ // Should use leave channel flow instead of remove
1525
+ });
1526
+ });
1527
+ });