@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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /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
|
+
}
|