@agent-relay/dashboard 2.0.81 → 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/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → 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,245 @@
1
+ /**
2
+ * Tests for usePresence hook - WebSocket message handling logic
3
+ *
4
+ * These tests focus on the message parsing and state update logic
5
+ * without requiring React Testing Library.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach } from 'vitest';
9
+ import type { UserPresence, TypingIndicator } from './usePresence';
10
+
11
+ // Test the message handling logic in isolation
12
+ describe('usePresence message handling', () => {
13
+ // Simulate the state update functions from the hook
14
+ let onlineUsers: UserPresence[] = [];
15
+ let typingUsers: TypingIndicator[] = [];
16
+ const currentUsername = 'testuser';
17
+
18
+ // Replicated message handling logic from the hook
19
+ function handleMessage(data: string) {
20
+ const msg = JSON.parse(data);
21
+
22
+ switch (msg.type) {
23
+ case 'presence_list':
24
+ onlineUsers = msg.users || [];
25
+ break;
26
+
27
+ case 'presence_join':
28
+ onlineUsers = onlineUsers.filter((u) => u.username !== msg.user.username);
29
+ onlineUsers.push(msg.user);
30
+ break;
31
+
32
+ case 'presence_leave':
33
+ onlineUsers = onlineUsers.filter((u) => u.username !== msg.username);
34
+ typingUsers = typingUsers.filter((t) => t.username !== msg.username);
35
+ break;
36
+
37
+ case 'typing':
38
+ // Ignore self
39
+ if (msg.username === currentUsername) break;
40
+
41
+ if (msg.isTyping) {
42
+ typingUsers = typingUsers.filter((t) => t.username !== msg.username);
43
+ typingUsers.push({
44
+ username: msg.username,
45
+ avatarUrl: msg.avatarUrl,
46
+ startedAt: Date.now(),
47
+ });
48
+ } else {
49
+ typingUsers = typingUsers.filter((t) => t.username !== msg.username);
50
+ }
51
+ break;
52
+ }
53
+ }
54
+
55
+ beforeEach(() => {
56
+ onlineUsers = [];
57
+ typingUsers = [];
58
+ });
59
+
60
+ describe('presence_list', () => {
61
+ it('should set online users from presence_list message', () => {
62
+ const users: UserPresence[] = [
63
+ { username: 'alice', connectedAt: '2024-01-01T00:00:00Z', lastSeen: '2024-01-01T00:00:00Z' },
64
+ { username: 'bob', avatarUrl: 'https://example.com/bob.jpg', connectedAt: '2024-01-01T00:00:00Z', lastSeen: '2024-01-01T00:00:00Z' },
65
+ ];
66
+
67
+ handleMessage(JSON.stringify({ type: 'presence_list', users }));
68
+
69
+ expect(onlineUsers).toEqual(users);
70
+ });
71
+
72
+ it('should handle empty users list', () => {
73
+ handleMessage(JSON.stringify({ type: 'presence_list', users: [] }));
74
+ expect(onlineUsers).toEqual([]);
75
+ });
76
+
77
+ it('should handle missing users field', () => {
78
+ handleMessage(JSON.stringify({ type: 'presence_list' }));
79
+ expect(onlineUsers).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe('presence_join', () => {
84
+ it('should add new user to online users', () => {
85
+ const user: UserPresence = {
86
+ username: 'alice',
87
+ avatarUrl: 'https://example.com/alice.jpg',
88
+ connectedAt: '2024-01-01T00:00:00Z',
89
+ lastSeen: '2024-01-01T00:00:00Z',
90
+ };
91
+
92
+ handleMessage(JSON.stringify({ type: 'presence_join', user }));
93
+
94
+ expect(onlineUsers).toHaveLength(1);
95
+ expect(onlineUsers[0].username).toBe('alice');
96
+ expect(onlineUsers[0].avatarUrl).toBe('https://example.com/alice.jpg');
97
+ });
98
+
99
+ it('should replace existing user with same username', () => {
100
+ // Add initial user
101
+ handleMessage(JSON.stringify({
102
+ type: 'presence_join',
103
+ user: { username: 'alice', avatarUrl: 'old.jpg', connectedAt: '2024-01-01T00:00:00Z', lastSeen: '2024-01-01T00:00:00Z' },
104
+ }));
105
+
106
+ // Update same user
107
+ handleMessage(JSON.stringify({
108
+ type: 'presence_join',
109
+ user: { username: 'alice', avatarUrl: 'new.jpg', connectedAt: '2024-01-01T01:00:00Z', lastSeen: '2024-01-01T01:00:00Z' },
110
+ }));
111
+
112
+ expect(onlineUsers).toHaveLength(1);
113
+ expect(onlineUsers[0].avatarUrl).toBe('new.jpg');
114
+ });
115
+ });
116
+
117
+ describe('presence_leave', () => {
118
+ it('should remove user from online users', () => {
119
+ // Add users
120
+ handleMessage(JSON.stringify({
121
+ type: 'presence_list',
122
+ users: [
123
+ { username: 'alice', connectedAt: '2024-01-01T00:00:00Z', lastSeen: '2024-01-01T00:00:00Z' },
124
+ { username: 'bob', connectedAt: '2024-01-01T00:00:00Z', lastSeen: '2024-01-01T00:00:00Z' },
125
+ ],
126
+ }));
127
+
128
+ expect(onlineUsers).toHaveLength(2);
129
+
130
+ // Remove alice
131
+ handleMessage(JSON.stringify({ type: 'presence_leave', username: 'alice' }));
132
+
133
+ expect(onlineUsers).toHaveLength(1);
134
+ expect(onlineUsers[0].username).toBe('bob');
135
+ });
136
+
137
+ it('should also remove user from typing users', () => {
138
+ // Add user typing
139
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: true }));
140
+ expect(typingUsers).toHaveLength(1);
141
+
142
+ // User leaves
143
+ handleMessage(JSON.stringify({ type: 'presence_leave', username: 'alice' }));
144
+
145
+ expect(typingUsers).toHaveLength(0);
146
+ });
147
+
148
+ it('should handle removing non-existent user', () => {
149
+ handleMessage(JSON.stringify({ type: 'presence_leave', username: 'nonexistent' }));
150
+ expect(onlineUsers).toEqual([]);
151
+ });
152
+ });
153
+
154
+ describe('typing', () => {
155
+ it('should add user to typing users when isTyping is true', () => {
156
+ handleMessage(JSON.stringify({
157
+ type: 'typing',
158
+ username: 'alice',
159
+ avatarUrl: 'https://example.com/alice.jpg',
160
+ isTyping: true,
161
+ }));
162
+
163
+ expect(typingUsers).toHaveLength(1);
164
+ expect(typingUsers[0].username).toBe('alice');
165
+ expect(typingUsers[0].avatarUrl).toBe('https://example.com/alice.jpg');
166
+ expect(typingUsers[0].startedAt).toBeGreaterThan(0);
167
+ });
168
+
169
+ it('should remove user from typing users when isTyping is false', () => {
170
+ // Start typing
171
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: true }));
172
+ expect(typingUsers).toHaveLength(1);
173
+
174
+ // Stop typing
175
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: false }));
176
+ expect(typingUsers).toHaveLength(0);
177
+ });
178
+
179
+ it('should ignore typing indicator from self', () => {
180
+ handleMessage(JSON.stringify({
181
+ type: 'typing',
182
+ username: 'testuser', // Same as currentUsername
183
+ isTyping: true,
184
+ }));
185
+
186
+ expect(typingUsers).toHaveLength(0);
187
+ });
188
+
189
+ it('should update typing user if they type again', async () => {
190
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: true }));
191
+ const firstStartedAt = typingUsers[0].startedAt;
192
+
193
+ // Wait sufficient time for timestamp differentiation (50ms is reliable across systems)
194
+ await new Promise(resolve => setTimeout(resolve, 50));
195
+
196
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: true }));
197
+
198
+ expect(typingUsers).toHaveLength(1);
199
+ // The startedAt should be updated (or at least the same since it uses Date.now())
200
+ expect(typingUsers[0].startedAt).toBeGreaterThanOrEqual(firstStartedAt);
201
+ });
202
+
203
+ it('should handle multiple users typing', () => {
204
+ handleMessage(JSON.stringify({ type: 'typing', username: 'alice', isTyping: true }));
205
+ handleMessage(JSON.stringify({ type: 'typing', username: 'bob', isTyping: true }));
206
+ handleMessage(JSON.stringify({ type: 'typing', username: 'charlie', isTyping: true }));
207
+
208
+ expect(typingUsers).toHaveLength(3);
209
+ expect(typingUsers.map(t => t.username)).toEqual(['alice', 'bob', 'charlie']);
210
+ });
211
+ });
212
+ });
213
+
214
+ describe('presence join message format', () => {
215
+ it('should construct correct join message', () => {
216
+ const user = { username: 'testuser', avatarUrl: 'https://example.com/test.jpg' };
217
+ const message = JSON.stringify({
218
+ type: 'presence',
219
+ action: 'join',
220
+ user,
221
+ });
222
+
223
+ const parsed = JSON.parse(message);
224
+ expect(parsed.type).toBe('presence');
225
+ expect(parsed.action).toBe('join');
226
+ expect(parsed.user.username).toBe('testuser');
227
+ expect(parsed.user.avatarUrl).toBe('https://example.com/test.jpg');
228
+ });
229
+ });
230
+
231
+ describe('typing message format', () => {
232
+ it('should construct correct typing message', () => {
233
+ const message = JSON.stringify({
234
+ type: 'typing',
235
+ isTyping: true,
236
+ username: 'testuser',
237
+ avatarUrl: 'https://example.com/test.jpg',
238
+ });
239
+
240
+ const parsed = JSON.parse(message);
241
+ expect(parsed.type).toBe('typing');
242
+ expect(parsed.isTyping).toBe(true);
243
+ expect(parsed.username).toBe('testuser');
244
+ });
245
+ });
@@ -0,0 +1,377 @@
1
+ /**
2
+ * usePresence Hook
3
+ *
4
+ * Manages user presence and typing indicators via WebSocket.
5
+ * - Tracks which users are currently online
6
+ * - Sends/receives typing indicator events
7
+ * - Handles user presence announcements
8
+ */
9
+
10
+ import { useState, useEffect, useCallback, useRef } from 'react';
11
+ import { getWebSocketUrl } from '../../lib/config';
12
+
13
+ /** User presence information */
14
+ export interface UserPresence {
15
+ /** Username (GitHub username in cloud mode) */
16
+ username: string;
17
+ /** Optional avatar URL */
18
+ avatarUrl?: string;
19
+ /** When the user came online */
20
+ connectedAt: string;
21
+ /** Last activity timestamp */
22
+ lastSeen: string;
23
+ /** Whether user is currently typing */
24
+ isTyping?: boolean;
25
+ }
26
+
27
+ /** Typing indicator information */
28
+ export interface TypingIndicator {
29
+ /** Username of the person typing */
30
+ username: string;
31
+ /** Avatar URL if available */
32
+ avatarUrl?: string;
33
+ /** Timestamp when typing started */
34
+ startedAt: number;
35
+ }
36
+
37
+ export interface UsePresenceOptions {
38
+ /** Current user info (if logged in) */
39
+ currentUser?: {
40
+ username: string;
41
+ avatarUrl?: string;
42
+ };
43
+ /** WebSocket URL (defaults to same as main WebSocket) */
44
+ wsUrl?: string;
45
+ /** Whether to auto-connect */
46
+ autoConnect?: boolean;
47
+ /** Optional handler for additional messages (e.g., channel_message) */
48
+ onEvent?: (event: any) => void;
49
+ /** Workspace ID for channel subscription (cloud mode) */
50
+ workspaceId?: string;
51
+ }
52
+
53
+ /** Connection quality state for UI indicators */
54
+ export type PresenceConnectionState = 'connected' | 'reconnecting' | 'disconnected';
55
+
56
+ export interface UsePresenceReturn {
57
+ /** List of online users */
58
+ onlineUsers: UserPresence[];
59
+ /** Currently typing users (excluding self) */
60
+ typingUsers: TypingIndicator[];
61
+ /** Send typing indicator */
62
+ sendTyping: (isTyping: boolean) => void;
63
+ /** Whether connected to presence system */
64
+ isConnected: boolean;
65
+ /** Granular connection quality: 'connected', 'reconnecting', or 'disconnected' */
66
+ connectionState: PresenceConnectionState;
67
+ }
68
+
69
+ /**
70
+ * Get the presence WebSocket URL using centralized config
71
+ */
72
+ function getPresenceUrl(): string {
73
+ return getWebSocketUrl('/ws/presence');
74
+ }
75
+
76
+ export function usePresence(options: UsePresenceOptions = {}): UsePresenceReturn {
77
+ const { currentUser, wsUrl, autoConnect = true, onEvent, workspaceId } = options;
78
+
79
+ const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([]);
80
+ const [typingUsers, setTypingUsers] = useState<TypingIndicator[]>([]);
81
+ const [isConnected, setIsConnected] = useState(false);
82
+ const [connectionState, setConnectionState] = useState<PresenceConnectionState>('disconnected');
83
+
84
+ const wsRef = useRef<WebSocket | null>(null);
85
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
86
+ const reconnectAttemptsRef = useRef(0);
87
+ const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
88
+ const isConnectingRef = useRef(false); // Prevent race conditions
89
+ const hasConnectedBeforeRef = useRef(false);
90
+ const currentUserRef = useRef(currentUser);
91
+ currentUserRef.current = currentUser; // Keep ref in sync with prop
92
+ const workspaceIdRef = useRef(workspaceId);
93
+ workspaceIdRef.current = workspaceId; // Keep ref in sync with prop
94
+ const onEventRef = useRef(onEvent);
95
+ onEventRef.current = onEvent; // Keep ref in sync with callback prop
96
+
97
+ // Clear stale typing indicators (after 3 seconds of no update)
98
+ useEffect(() => {
99
+ const interval = setInterval(() => {
100
+ const now = Date.now();
101
+ setTypingUsers((prev) =>
102
+ prev.filter((t) => now - t.startedAt < 3000)
103
+ );
104
+ }, 1000);
105
+
106
+ return () => clearInterval(interval);
107
+ }, []);
108
+
109
+ const connect = useCallback(() => {
110
+ const user = currentUserRef.current;
111
+ if (!user) return; // Don't connect without user info
112
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
113
+ if (isConnectingRef.current) return; // Prevent concurrent connect attempts
114
+
115
+ // Track reconnection state
116
+ if (hasConnectedBeforeRef.current) {
117
+ setConnectionState('reconnecting');
118
+ }
119
+
120
+ isConnectingRef.current = true;
121
+ const url = wsUrl || getPresenceUrl();
122
+
123
+ try {
124
+ const ws = new WebSocket(url);
125
+
126
+ ws.onopen = () => {
127
+ isConnectingRef.current = false;
128
+ setIsConnected(true);
129
+ setConnectionState('connected');
130
+ reconnectAttemptsRef.current = 0;
131
+ hasConnectedBeforeRef.current = true;
132
+
133
+ // Announce presence (use ref to get latest user info)
134
+ const currentUserInfo = currentUserRef.current;
135
+ if (currentUserInfo) {
136
+ ws.send(JSON.stringify({
137
+ type: 'presence',
138
+ action: 'join',
139
+ user: {
140
+ username: currentUserInfo.username,
141
+ avatarUrl: currentUserInfo.avatarUrl,
142
+ },
143
+ }));
144
+
145
+ // Subscribe to channel messages for this workspace (cloud mode)
146
+ // This enables receiving real-time channel messages from other users
147
+ const wsId = workspaceIdRef.current;
148
+ if (wsId) {
149
+ ws.send(JSON.stringify({
150
+ type: 'subscribe_channels',
151
+ workspaceId: wsId,
152
+ }));
153
+ }
154
+ }
155
+ };
156
+
157
+ ws.onclose = () => {
158
+ isConnectingRef.current = false;
159
+ setIsConnected(false);
160
+ wsRef.current = null;
161
+
162
+ // Reconnect with backoff and jitter (only if not intentionally disconnected)
163
+ if (currentUserRef.current) {
164
+ setConnectionState('reconnecting');
165
+ const baseDelay = Math.min(
166
+ 500 * Math.pow(2, reconnectAttemptsRef.current),
167
+ 15000
168
+ );
169
+ // Add jitter to prevent thundering herd
170
+ const delay = Math.round(baseDelay * (0.5 + Math.random() * 0.5));
171
+ reconnectAttemptsRef.current++;
172
+
173
+ console.log(`[WS:Presence] Reconnecting (attempt ${reconnectAttemptsRef.current})...`);
174
+
175
+ reconnectTimeoutRef.current = setTimeout(() => {
176
+ connect();
177
+ }, delay);
178
+ } else {
179
+ setConnectionState('disconnected');
180
+ }
181
+ };
182
+
183
+ ws.onerror = (event) => {
184
+ console.error('[usePresence] Error:', event);
185
+ };
186
+
187
+ ws.onmessage = (event) => {
188
+ try {
189
+ const msg = JSON.parse(event.data);
190
+
191
+ switch (msg.type) {
192
+ case 'presence_list':
193
+ // Full list of online users
194
+ setOnlineUsers(msg.users || []);
195
+ break;
196
+
197
+ case 'presence_join':
198
+ // User came online
199
+ setOnlineUsers((prev) => {
200
+ const filtered = prev.filter((u) => u.username !== msg.user.username);
201
+ return [...filtered, msg.user];
202
+ });
203
+ // Also forward to onEvent for activity feed
204
+ onEventRef.current?.(msg);
205
+ break;
206
+
207
+ case 'presence_leave':
208
+ // User went offline
209
+ setOnlineUsers((prev) =>
210
+ prev.filter((u) => u.username !== msg.username)
211
+ );
212
+ setTypingUsers((prev) =>
213
+ prev.filter((t) => t.username !== msg.username)
214
+ );
215
+ // Also forward to onEvent for activity feed
216
+ onEventRef.current?.(msg);
217
+ break;
218
+
219
+ case 'typing':
220
+ // Typing indicator update
221
+ if (msg.username === currentUserRef.current?.username) break; // Ignore self
222
+
223
+ if (msg.isTyping) {
224
+ setTypingUsers((prev) => {
225
+ const filtered = prev.filter((t) => t.username !== msg.username);
226
+ return [
227
+ ...filtered,
228
+ {
229
+ username: msg.username,
230
+ avatarUrl: msg.avatarUrl,
231
+ startedAt: Date.now(),
232
+ },
233
+ ];
234
+ });
235
+ } else {
236
+ setTypingUsers((prev) =>
237
+ prev.filter((t) => t.username !== msg.username)
238
+ );
239
+ }
240
+ break;
241
+
242
+ default:
243
+ onEventRef.current?.(msg);
244
+ }
245
+ } catch (e) {
246
+ console.error('[usePresence] Failed to parse message:', e);
247
+ }
248
+ };
249
+
250
+ wsRef.current = ws;
251
+ } catch (e) {
252
+ console.error('[usePresence] Failed to create WebSocket:', e);
253
+ }
254
+ }, [wsUrl]); // Use ref for currentUser to avoid dependency
255
+
256
+ const disconnect = useCallback(() => {
257
+ // Clear reconnect timeout first
258
+ if (reconnectTimeoutRef.current) {
259
+ clearTimeout(reconnectTimeoutRef.current);
260
+ reconnectTimeoutRef.current = null;
261
+ }
262
+
263
+ // Reset connecting flag
264
+ isConnectingRef.current = false;
265
+
266
+ if (wsRef.current) {
267
+ // Prevent auto-reconnect by removing onclose handler before closing
268
+ const ws = wsRef.current;
269
+ ws.onclose = null;
270
+ ws.onerror = null;
271
+
272
+ // Send leave message before closing
273
+ const user = currentUserRef.current;
274
+ if (ws.readyState === WebSocket.OPEN && user) {
275
+ ws.send(JSON.stringify({
276
+ type: 'presence',
277
+ action: 'leave',
278
+ username: user.username,
279
+ }));
280
+ }
281
+ ws.close();
282
+ wsRef.current = null;
283
+ }
284
+
285
+ setIsConnected(false);
286
+ setConnectionState('disconnected');
287
+ }, []); // Use ref for currentUser to avoid dependency
288
+
289
+ // Send typing indicator
290
+ const sendTyping = useCallback((isTyping: boolean) => {
291
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
292
+ const user = currentUserRef.current;
293
+ if (!user) return;
294
+
295
+ // Clear any existing timeout first
296
+ if (typingTimeoutRef.current) {
297
+ clearTimeout(typingTimeoutRef.current);
298
+ typingTimeoutRef.current = null;
299
+ }
300
+
301
+ wsRef.current.send(JSON.stringify({
302
+ type: 'typing',
303
+ isTyping,
304
+ username: user.username,
305
+ avatarUrl: user.avatarUrl,
306
+ }));
307
+
308
+ // Only set auto-clear timeout when starting to type
309
+ if (isTyping) {
310
+ typingTimeoutRef.current = setTimeout(() => {
311
+ typingTimeoutRef.current = null;
312
+ sendTyping(false);
313
+ }, 3000);
314
+ }
315
+ }, []); // Use ref for currentUser to avoid dependency
316
+
317
+ // Connect when user is available
318
+ useEffect(() => {
319
+ if (!autoConnect || !currentUserRef.current) return;
320
+
321
+ // Prevent connecting if already connected or connecting
322
+ if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
323
+ return;
324
+ }
325
+
326
+ connect();
327
+
328
+ return () => {
329
+ disconnect();
330
+ };
331
+ // Callbacks are now stable (use refs internally), so only need to depend on user identity
332
+ }, [autoConnect, currentUser?.username, connect, disconnect]);
333
+
334
+ // Send leave on page unload
335
+ useEffect(() => {
336
+ const handleUnload = () => {
337
+ const user = currentUserRef.current;
338
+ if (wsRef.current?.readyState === WebSocket.OPEN && user) {
339
+ wsRef.current.send(JSON.stringify({
340
+ type: 'presence',
341
+ action: 'leave',
342
+ username: user.username,
343
+ }));
344
+ }
345
+ };
346
+
347
+ window.addEventListener('beforeunload', handleUnload);
348
+ return () => window.removeEventListener('beforeunload', handleUnload);
349
+ }, []); // Use ref for currentUser to avoid dependency
350
+
351
+ // Visibility change listener: reconnect when tab becomes visible
352
+ useEffect(() => {
353
+ const handleVisibilityChange = () => {
354
+ if (document.visibilityState === 'visible') {
355
+ // Check if connection is dead and reconnect
356
+ if (currentUserRef.current && (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED)) {
357
+ console.log('[WS:Presence] Tab visible, reconnecting...');
358
+ reconnectAttemptsRef.current = 0; // Reset attempts for visibility-triggered reconnect
359
+ connect();
360
+ }
361
+ }
362
+ };
363
+
364
+ document.addEventListener('visibilitychange', handleVisibilityChange);
365
+ return () => {
366
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
367
+ };
368
+ }, [connect]);
369
+
370
+ return {
371
+ onlineUsers,
372
+ typingUsers,
373
+ sendTyping,
374
+ isConnected,
375
+ connectionState,
376
+ };
377
+ }