@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.
- 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/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → 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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTrajectory Hook
|
|
3
|
+
*
|
|
4
|
+
* Fetches and polls trajectory data from the API.
|
|
5
|
+
* Provides real-time updates on agent work progress.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
|
+
import type { TrajectoryStep } from '../TrajectoryViewer';
|
|
10
|
+
import { getApiUrl } from '../../lib/api';
|
|
11
|
+
|
|
12
|
+
interface TrajectoryStatus {
|
|
13
|
+
active: boolean;
|
|
14
|
+
trajectoryId?: string;
|
|
15
|
+
phase?: 'plan' | 'design' | 'execute' | 'review' | 'observe';
|
|
16
|
+
task?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TrajectoryHistoryEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
status: 'active' | 'completed' | 'abandoned';
|
|
23
|
+
startedAt: string;
|
|
24
|
+
completedAt?: string;
|
|
25
|
+
agents?: string[];
|
|
26
|
+
summary?: string;
|
|
27
|
+
confidence?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface UseTrajectoryOptions {
|
|
31
|
+
/** Polling interval in ms (default: 2000) */
|
|
32
|
+
pollInterval?: number;
|
|
33
|
+
/** Whether to auto-poll (default: true) */
|
|
34
|
+
autoPoll?: boolean;
|
|
35
|
+
/** Specific trajectory ID to fetch */
|
|
36
|
+
trajectoryId?: string;
|
|
37
|
+
/** API base URL (for when running outside default context) */
|
|
38
|
+
apiBaseUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UseTrajectoryResult {
|
|
42
|
+
steps: TrajectoryStep[];
|
|
43
|
+
status: TrajectoryStatus | null;
|
|
44
|
+
history: TrajectoryHistoryEntry[];
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
error: string | null;
|
|
47
|
+
refresh: () => Promise<void>;
|
|
48
|
+
selectTrajectory: (id: string | null) => void;
|
|
49
|
+
selectedTrajectoryId: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useTrajectory(options: UseTrajectoryOptions = {}): UseTrajectoryResult {
|
|
53
|
+
const {
|
|
54
|
+
pollInterval = 2000,
|
|
55
|
+
autoPoll = true,
|
|
56
|
+
trajectoryId: initialTrajectoryId,
|
|
57
|
+
apiBaseUrl = '',
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
const [steps, setSteps] = useState<TrajectoryStep[]>([]);
|
|
61
|
+
const [status, setStatus] = useState<TrajectoryStatus | null>(null);
|
|
62
|
+
const [history, setHistory] = useState<TrajectoryHistoryEntry[]>([]);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [selectedTrajectoryId, setSelectedTrajectoryId] = useState<string | null>(initialTrajectoryId || null);
|
|
66
|
+
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
|
67
|
+
const hasLoadedInitialStepsRef = useRef(false);
|
|
68
|
+
const hasInitializedRef = useRef(false);
|
|
69
|
+
// Track the latest selection to prevent stale fetches from overwriting data
|
|
70
|
+
const latestSelectionRef = useRef<string | null>(selectedTrajectoryId);
|
|
71
|
+
// Request counter to ensure only the most recent fetch updates state
|
|
72
|
+
// This is more robust than trajectory ID comparison for handling race conditions
|
|
73
|
+
const requestCounterRef = useRef(0);
|
|
74
|
+
|
|
75
|
+
// Fetch trajectory status
|
|
76
|
+
const fetchStatus = useCallback(async () => {
|
|
77
|
+
try {
|
|
78
|
+
// Use apiBaseUrl if provided, otherwise use getApiUrl for cloud mode routing
|
|
79
|
+
const url = apiBaseUrl
|
|
80
|
+
? `${apiBaseUrl}/api/trajectory`
|
|
81
|
+
: getApiUrl('/api/trajectory');
|
|
82
|
+
const response = await fetch(url, { credentials: 'include' });
|
|
83
|
+
const data = await response.json();
|
|
84
|
+
|
|
85
|
+
if (data.success !== false) {
|
|
86
|
+
setStatus({
|
|
87
|
+
active: data.active,
|
|
88
|
+
trajectoryId: data.trajectoryId,
|
|
89
|
+
phase: data.phase,
|
|
90
|
+
task: data.task,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('[useTrajectory] Status fetch error:', err);
|
|
95
|
+
}
|
|
96
|
+
}, [apiBaseUrl]);
|
|
97
|
+
|
|
98
|
+
// Fetch trajectory history
|
|
99
|
+
const fetchHistory = useCallback(async () => {
|
|
100
|
+
try {
|
|
101
|
+
const url = apiBaseUrl
|
|
102
|
+
? `${apiBaseUrl}/api/trajectory/history`
|
|
103
|
+
: getApiUrl('/api/trajectory/history');
|
|
104
|
+
const response = await fetch(url, { credentials: 'include' });
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
|
|
107
|
+
if (data.success) {
|
|
108
|
+
setHistory(data.trajectories || []);
|
|
109
|
+
}
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
console.error('[useTrajectory] History fetch error:', err);
|
|
112
|
+
}
|
|
113
|
+
}, [apiBaseUrl]);
|
|
114
|
+
|
|
115
|
+
// Fetch trajectory steps
|
|
116
|
+
const fetchSteps = useCallback(async () => {
|
|
117
|
+
// Increment request counter and capture it for this request
|
|
118
|
+
// This ensures only the most recent request updates state
|
|
119
|
+
const requestId = ++requestCounterRef.current;
|
|
120
|
+
const trajectoryId = selectedTrajectoryId;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const basePath = trajectoryId
|
|
124
|
+
? `/api/trajectory/steps?trajectoryId=${encodeURIComponent(trajectoryId)}`
|
|
125
|
+
: '/api/trajectory/steps';
|
|
126
|
+
const url = apiBaseUrl
|
|
127
|
+
? `${apiBaseUrl}${basePath}`
|
|
128
|
+
: getApiUrl(basePath);
|
|
129
|
+
|
|
130
|
+
const response = await fetch(url, { credentials: 'include' });
|
|
131
|
+
const data = await response.json();
|
|
132
|
+
|
|
133
|
+
// Only update state if this is still the most recent request
|
|
134
|
+
// Check both request counter AND trajectory ID for double protection
|
|
135
|
+
if (requestId !== requestCounterRef.current) {
|
|
136
|
+
console.log('[useTrajectory] Ignoring superseded fetch (request', requestId, 'current', requestCounterRef.current, ')');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (trajectoryId !== latestSelectionRef.current) {
|
|
140
|
+
console.log('[useTrajectory] Ignoring stale fetch for', trajectoryId, 'current is', latestSelectionRef.current);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (data.success) {
|
|
145
|
+
setSteps(data.steps || []);
|
|
146
|
+
setError(null);
|
|
147
|
+
} else {
|
|
148
|
+
setError(data.error || 'Failed to fetch trajectory steps');
|
|
149
|
+
}
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
// Only update error state if this is still the current request
|
|
152
|
+
if (requestId === requestCounterRef.current && trajectoryId === latestSelectionRef.current) {
|
|
153
|
+
console.error('[useTrajectory] Steps fetch error:', err);
|
|
154
|
+
setError(err.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}, [apiBaseUrl, selectedTrajectoryId]);
|
|
158
|
+
|
|
159
|
+
// Select a specific trajectory
|
|
160
|
+
const selectTrajectory = useCallback((id: string | null) => {
|
|
161
|
+
// Normalize empty string to null for consistency
|
|
162
|
+
const normalizedId = id === '' ? null : id;
|
|
163
|
+
|
|
164
|
+
// Skip if already selected (prevents unnecessary re-fetches)
|
|
165
|
+
if (normalizedId === selectedTrajectoryId) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Increment request counter to invalidate any in-flight fetches immediately
|
|
170
|
+
// This is crucial - it ensures that even if an old fetch completes after this,
|
|
171
|
+
// its request ID won't match and it will be ignored
|
|
172
|
+
requestCounterRef.current++;
|
|
173
|
+
|
|
174
|
+
// Update the ref immediately so in-flight fetches for other trajectories are ignored
|
|
175
|
+
latestSelectionRef.current = normalizedId;
|
|
176
|
+
|
|
177
|
+
// Clear steps immediately when switching trajectories to prevent showing stale data
|
|
178
|
+
setSteps([]);
|
|
179
|
+
|
|
180
|
+
// Set loading immediately to avoid flash of empty state before effect runs
|
|
181
|
+
if (normalizedId !== null) {
|
|
182
|
+
setIsLoading(true);
|
|
183
|
+
}
|
|
184
|
+
setSelectedTrajectoryId(normalizedId);
|
|
185
|
+
}, [selectedTrajectoryId]);
|
|
186
|
+
|
|
187
|
+
// Combined refresh function
|
|
188
|
+
const refresh = useCallback(async () => {
|
|
189
|
+
setIsLoading(true);
|
|
190
|
+
await Promise.all([fetchStatus(), fetchSteps(), fetchHistory()]);
|
|
191
|
+
setIsLoading(false);
|
|
192
|
+
}, [fetchStatus, fetchSteps, fetchHistory]);
|
|
193
|
+
|
|
194
|
+
// Keep the latestSelectionRef in sync with state
|
|
195
|
+
// This handles the initial value and any external changes
|
|
196
|
+
// Note: selectedTrajectoryId is already normalized by selectTrajectory
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
latestSelectionRef.current = selectedTrajectoryId;
|
|
199
|
+
}, [selectedTrajectoryId]);
|
|
200
|
+
|
|
201
|
+
// Initial fetch - only run once on mount
|
|
202
|
+
// Note: Empty deps array is intentional - we use hasInitializedRef to ensure single execution
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (hasInitializedRef.current) return;
|
|
205
|
+
hasInitializedRef.current = true;
|
|
206
|
+
refresh();
|
|
207
|
+
}, [refresh]);
|
|
208
|
+
|
|
209
|
+
// Re-fetch steps when selected trajectory changes
|
|
210
|
+
// Note: Initial fetch is handled by the refresh() call in the mount effect
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
// Skip the initial render - refresh() handles it
|
|
213
|
+
if (!hasLoadedInitialStepsRef.current) {
|
|
214
|
+
hasLoadedInitialStepsRef.current = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// For subsequent selection changes, fetch with loading state management
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
setIsLoading(true);
|
|
221
|
+
fetchSteps().finally(() => {
|
|
222
|
+
if (!cancelled) {
|
|
223
|
+
setIsLoading(false);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return () => {
|
|
228
|
+
cancelled = true;
|
|
229
|
+
};
|
|
230
|
+
}, [selectedTrajectoryId, fetchSteps]);
|
|
231
|
+
|
|
232
|
+
// Polling
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!autoPoll) return;
|
|
235
|
+
|
|
236
|
+
pollingRef.current = setInterval(() => {
|
|
237
|
+
fetchSteps();
|
|
238
|
+
fetchStatus();
|
|
239
|
+
// Poll history less frequently
|
|
240
|
+
}, pollInterval);
|
|
241
|
+
|
|
242
|
+
// Poll history every 10 seconds
|
|
243
|
+
const historyPollRef = setInterval(fetchHistory, 10000);
|
|
244
|
+
|
|
245
|
+
return () => {
|
|
246
|
+
if (pollingRef.current) {
|
|
247
|
+
clearInterval(pollingRef.current);
|
|
248
|
+
}
|
|
249
|
+
clearInterval(historyPollRef);
|
|
250
|
+
};
|
|
251
|
+
}, [autoPoll, pollInterval, fetchSteps, fetchStatus, fetchHistory]);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
steps,
|
|
255
|
+
status,
|
|
256
|
+
history,
|
|
257
|
+
isLoading,
|
|
258
|
+
error,
|
|
259
|
+
refresh,
|
|
260
|
+
selectTrajectory,
|
|
261
|
+
selectedTrajectoryId,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default useTrajectory;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWebSocket Hook
|
|
3
|
+
*
|
|
4
|
+
* React hook for managing WebSocket connection to the dashboard server.
|
|
5
|
+
* Provides real-time updates for agents, messages, and fleet data.
|
|
6
|
+
*
|
|
7
|
+
* Supports message replay on reconnect: tracks the last received sequence
|
|
8
|
+
* number (`seq`) and requests missed messages from the server after reconnecting.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
12
|
+
import type { Agent, Message, Session, AgentSummary, FleetData } from '../../types';
|
|
13
|
+
import { getWebSocketUrl } from '../../lib/config';
|
|
14
|
+
|
|
15
|
+
export interface DashboardData {
|
|
16
|
+
agents: Agent[];
|
|
17
|
+
users?: Agent[]; // Human users (cli === 'dashboard')
|
|
18
|
+
messages: Message[];
|
|
19
|
+
sessions?: Session[];
|
|
20
|
+
summaries?: AgentSummary[];
|
|
21
|
+
fleet?: FleetData;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseWebSocketOptions {
|
|
25
|
+
url?: string;
|
|
26
|
+
autoConnect?: boolean;
|
|
27
|
+
reconnect?: boolean;
|
|
28
|
+
maxReconnectAttempts?: number;
|
|
29
|
+
reconnectDelay?: number;
|
|
30
|
+
/** Callback for non-data events like direct_message, channel_message */
|
|
31
|
+
onEvent?: (event: WebSocketEvent) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Event types received on the WebSocket (non-data messages) */
|
|
35
|
+
export interface WebSocketEvent {
|
|
36
|
+
type: 'direct_message' | 'channel_message' | 'presence_update' | 'typing' | string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Connection quality state for UI indicators */
|
|
41
|
+
export type ConnectionState = 'connected' | 'reconnecting' | 'disconnected';
|
|
42
|
+
|
|
43
|
+
export interface UseWebSocketReturn {
|
|
44
|
+
data: DashboardData | null;
|
|
45
|
+
isConnected: boolean;
|
|
46
|
+
/** Granular connection quality: 'connected', 'reconnecting', or 'disconnected' */
|
|
47
|
+
connectionState: ConnectionState;
|
|
48
|
+
error: Error | null;
|
|
49
|
+
connect: () => void;
|
|
50
|
+
disconnect: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_OPTIONS: Omit<Required<UseWebSocketOptions>, 'onEvent'> & { onEvent?: (event: WebSocketEvent) => void } = {
|
|
54
|
+
url: '',
|
|
55
|
+
autoConnect: true,
|
|
56
|
+
reconnect: true,
|
|
57
|
+
maxReconnectAttempts: 10,
|
|
58
|
+
reconnectDelay: 500,
|
|
59
|
+
onEvent: undefined,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the default WebSocket URL based on the current page location.
|
|
64
|
+
* Uses centralized config for consistent URL resolution.
|
|
65
|
+
*/
|
|
66
|
+
function getDefaultUrl(): string {
|
|
67
|
+
return getWebSocketUrl('/ws');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
|
|
71
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
72
|
+
|
|
73
|
+
const [data, setData] = useState<DashboardData | null>(null);
|
|
74
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
75
|
+
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
76
|
+
const [error, setError] = useState<Error | null>(null);
|
|
77
|
+
|
|
78
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
79
|
+
const reconnectAttemptsRef = useRef(0);
|
|
80
|
+
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
81
|
+
const onEventRef = useRef(opts.onEvent);
|
|
82
|
+
onEventRef.current = opts.onEvent; // Keep ref in sync with callback prop
|
|
83
|
+
|
|
84
|
+
// Sequence tracking for replay support (refs to avoid re-renders)
|
|
85
|
+
const lastSeqRef = useRef<number | null>(null);
|
|
86
|
+
// Track whether the server supports replay (sends a 'sync' message on connect)
|
|
87
|
+
const serverSupportsReplayRef = useRef(false);
|
|
88
|
+
// Set of already-processed seq numbers for deduplication
|
|
89
|
+
const processedSeqsRef = useRef(new Set<number>());
|
|
90
|
+
// Whether this is a reconnection (not the first connection)
|
|
91
|
+
const hasConnectedBeforeRef = useRef(false);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Process a single message payload, deduplicating by seq.
|
|
95
|
+
* Returns true if the message was processed, false if skipped (duplicate).
|
|
96
|
+
*/
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
|
+
const processMessage = useCallback((parsed: any) => {
|
|
99
|
+
// Extract seq if present
|
|
100
|
+
const seq = typeof parsed.seq === 'number' ? parsed.seq : null;
|
|
101
|
+
|
|
102
|
+
// Deduplicate: skip if we already processed this seq
|
|
103
|
+
if (seq !== null && processedSeqsRef.current.has(seq)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Track this seq
|
|
108
|
+
if (seq !== null) {
|
|
109
|
+
processedSeqsRef.current.add(seq);
|
|
110
|
+
lastSeqRef.current = seq;
|
|
111
|
+
|
|
112
|
+
// Keep the set from growing unbounded - only track recent seqs
|
|
113
|
+
if (processedSeqsRef.current.size > 1000) {
|
|
114
|
+
const seqs = Array.from(processedSeqsRef.current).sort((a, b) => a - b);
|
|
115
|
+
const toRemove = seqs.slice(0, seqs.length - 500);
|
|
116
|
+
for (const s of toRemove) {
|
|
117
|
+
processedSeqsRef.current.delete(s);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Strip seq from the payload before routing (it's only for tracking, not data)
|
|
123
|
+
const { seq: _seq, ...payload } = parsed;
|
|
124
|
+
|
|
125
|
+
// Check if this is an event message (has a 'type' field like direct_message, channel_message)
|
|
126
|
+
// vs dashboard data (has agents array)
|
|
127
|
+
if (payload && typeof payload === 'object' && 'type' in payload && typeof payload.type === 'string') {
|
|
128
|
+
// This is an event message - route to callback
|
|
129
|
+
onEventRef.current?.(payload as WebSocketEvent);
|
|
130
|
+
} else {
|
|
131
|
+
// This is dashboard data - update state
|
|
132
|
+
setData(payload as DashboardData);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const connect = useCallback(() => {
|
|
139
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Compute URL at connection time (always on client)
|
|
144
|
+
const wsUrl = opts.url || getDefaultUrl();
|
|
145
|
+
|
|
146
|
+
// If we have had a prior connection, we are reconnecting
|
|
147
|
+
if (hasConnectedBeforeRef.current) {
|
|
148
|
+
setConnectionState('reconnecting');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const ws = new WebSocket(wsUrl);
|
|
153
|
+
|
|
154
|
+
ws.onopen = () => {
|
|
155
|
+
setIsConnected(true);
|
|
156
|
+
setConnectionState('connected');
|
|
157
|
+
setError(null);
|
|
158
|
+
reconnectAttemptsRef.current = 0;
|
|
159
|
+
|
|
160
|
+
// On reconnect, request replay of missed messages
|
|
161
|
+
if (hasConnectedBeforeRef.current && lastSeqRef.current !== null && serverSupportsReplayRef.current) {
|
|
162
|
+
console.log(`[WS] Requesting replay from seq ${lastSeqRef.current}`);
|
|
163
|
+
ws.send(JSON.stringify({
|
|
164
|
+
type: 'replay',
|
|
165
|
+
lastSequenceId: lastSeqRef.current,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
hasConnectedBeforeRef.current = true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
ws.onclose = () => {
|
|
173
|
+
setIsConnected(false);
|
|
174
|
+
wsRef.current = null;
|
|
175
|
+
|
|
176
|
+
// Schedule reconnect if enabled
|
|
177
|
+
if (opts.reconnect && reconnectAttemptsRef.current < opts.maxReconnectAttempts) {
|
|
178
|
+
setConnectionState('reconnecting');
|
|
179
|
+
const baseDelay = Math.min(
|
|
180
|
+
opts.reconnectDelay * Math.pow(2, reconnectAttemptsRef.current),
|
|
181
|
+
15000
|
|
182
|
+
);
|
|
183
|
+
// Add jitter to prevent thundering herd
|
|
184
|
+
const delay = Math.round(baseDelay * (0.5 + Math.random() * 0.5));
|
|
185
|
+
reconnectAttemptsRef.current++;
|
|
186
|
+
|
|
187
|
+
console.log(`[WS] Reconnecting (attempt ${reconnectAttemptsRef.current})...`);
|
|
188
|
+
|
|
189
|
+
reconnectTimeoutRef.current = setTimeout(() => {
|
|
190
|
+
connect();
|
|
191
|
+
}, delay);
|
|
192
|
+
} else {
|
|
193
|
+
setConnectionState('disconnected');
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
ws.onerror = (event) => {
|
|
198
|
+
setError(new Error('WebSocket connection error'));
|
|
199
|
+
console.error('[useWebSocket] Error:', event);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
ws.onmessage = (event) => {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(event.data);
|
|
205
|
+
|
|
206
|
+
// Handle sync message from server (sent on initial connection)
|
|
207
|
+
if (parsed && parsed.type === 'sync' && typeof parsed.sequenceId === 'number') {
|
|
208
|
+
serverSupportsReplayRef.current = true;
|
|
209
|
+
// If we don't have a lastSeq yet, initialize from server
|
|
210
|
+
if (lastSeqRef.current === null) {
|
|
211
|
+
lastSeqRef.current = parsed.sequenceId;
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Handle replay response: array of missed messages
|
|
217
|
+
if (parsed && parsed.type === 'replay' && Array.isArray(parsed.messages)) {
|
|
218
|
+
for (const msg of parsed.messages) {
|
|
219
|
+
processMessage(msg);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Normal message processing with dedup
|
|
225
|
+
processMessage(parsed);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error('[useWebSocket] Failed to parse message:', e);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
wsRef.current = ws;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
setError(e instanceof Error ? e : new Error('Failed to create WebSocket'));
|
|
234
|
+
}
|
|
235
|
+
}, [opts.url, opts.reconnect, opts.maxReconnectAttempts, opts.reconnectDelay, processMessage]);
|
|
236
|
+
|
|
237
|
+
const disconnect = useCallback(() => {
|
|
238
|
+
if (reconnectTimeoutRef.current) {
|
|
239
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
240
|
+
reconnectTimeoutRef.current = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (wsRef.current) {
|
|
244
|
+
wsRef.current.close();
|
|
245
|
+
wsRef.current = null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
setIsConnected(false);
|
|
249
|
+
setConnectionState('disconnected');
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
// Auto-connect on mount
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (opts.autoConnect) {
|
|
255
|
+
connect();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return () => {
|
|
259
|
+
disconnect();
|
|
260
|
+
};
|
|
261
|
+
}, [opts.autoConnect, connect, disconnect]);
|
|
262
|
+
|
|
263
|
+
// Visibility change listener: reconnect when tab becomes visible
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const handleVisibilityChange = () => {
|
|
266
|
+
if (document.visibilityState === 'visible') {
|
|
267
|
+
// Check if connection is dead and reconnect
|
|
268
|
+
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
|
|
269
|
+
console.log('[WS] Tab visible, reconnecting...');
|
|
270
|
+
reconnectAttemptsRef.current = 0; // Reset attempts for visibility-triggered reconnect
|
|
271
|
+
connect();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
277
|
+
return () => {
|
|
278
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
279
|
+
};
|
|
280
|
+
}, [connect]);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
data,
|
|
284
|
+
isConnected,
|
|
285
|
+
connectionState,
|
|
286
|
+
error,
|
|
287
|
+
connect,
|
|
288
|
+
disconnect,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWorkspaceMembers Hook
|
|
3
|
+
*
|
|
4
|
+
* Fetches and caches workspace members for filtering online users.
|
|
5
|
+
* Returns the set of usernames that have access to the workspace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
9
|
+
import { cloudApi } from '../../lib/cloudApi';
|
|
10
|
+
import type { UserPresence } from './usePresence';
|
|
11
|
+
|
|
12
|
+
interface WorkspaceMember {
|
|
13
|
+
id: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
role: string;
|
|
16
|
+
isPending: boolean;
|
|
17
|
+
user?: {
|
|
18
|
+
githubUsername?: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
avatarUrl?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseWorkspaceMembersOptions {
|
|
26
|
+
/** The workspace ID to fetch members for */
|
|
27
|
+
workspaceId?: string;
|
|
28
|
+
/** Whether to enable fetching (e.g., only in cloud mode) */
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseWorkspaceMembersReturn {
|
|
33
|
+
/** Set of usernames with workspace access (lowercase for comparison) */
|
|
34
|
+
memberUsernames: Set<string>;
|
|
35
|
+
/** Whether members are currently loading */
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
/** Error message if fetch failed */
|
|
38
|
+
error: string | null;
|
|
39
|
+
/** Refetch workspace members */
|
|
40
|
+
refetch: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hook to fetch workspace members and provide a set of usernames with access.
|
|
45
|
+
* Used to filter online users to show only those with workspace access.
|
|
46
|
+
*/
|
|
47
|
+
export function useWorkspaceMembers(
|
|
48
|
+
options: UseWorkspaceMembersOptions = {}
|
|
49
|
+
): UseWorkspaceMembersReturn {
|
|
50
|
+
const { workspaceId, enabled = true } = options;
|
|
51
|
+
|
|
52
|
+
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
|
53
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
54
|
+
const [error, setError] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const fetchMembers = useCallback(async () => {
|
|
57
|
+
if (!workspaceId || !enabled) {
|
|
58
|
+
setMembers([]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setIsLoading(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await cloudApi.getWorkspaceMembers(workspaceId);
|
|
67
|
+
if (result.success) {
|
|
68
|
+
setMembers(result.data.members as WorkspaceMember[]);
|
|
69
|
+
} else {
|
|
70
|
+
setError(result.error);
|
|
71
|
+
setMembers([]);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch members');
|
|
75
|
+
setMembers([]);
|
|
76
|
+
} finally {
|
|
77
|
+
setIsLoading(false);
|
|
78
|
+
}
|
|
79
|
+
}, [workspaceId, enabled]);
|
|
80
|
+
|
|
81
|
+
// Fetch members when workspace changes
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
fetchMembers();
|
|
84
|
+
}, [fetchMembers]);
|
|
85
|
+
|
|
86
|
+
// Build set of member usernames (lowercase for case-insensitive comparison)
|
|
87
|
+
// Include githubUsername, displayName, and email prefix to match all possible presence usernames
|
|
88
|
+
// (The auth API uses: displayName || githubUsername || email.split('@')[0])
|
|
89
|
+
const memberUsernames = useMemo(() => {
|
|
90
|
+
const usernames = new Set<string>();
|
|
91
|
+
for (const member of members) {
|
|
92
|
+
// Add GitHub username if available
|
|
93
|
+
if (member.user?.githubUsername) {
|
|
94
|
+
usernames.add(member.user.githubUsername.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
// Also add displayName for email-only users who don't have a GitHub username
|
|
97
|
+
if (member.user?.displayName) {
|
|
98
|
+
usernames.add(member.user.displayName.toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
// Also add email prefix for email-only users without displayName
|
|
101
|
+
if (member.user?.email) {
|
|
102
|
+
usernames.add(member.user.email.split('@')[0].toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return usernames;
|
|
106
|
+
}, [members]);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
memberUsernames,
|
|
110
|
+
isLoading,
|
|
111
|
+
error,
|
|
112
|
+
refetch: fetchMembers,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Filter online users to only include those with workspace access.
|
|
118
|
+
* If no members are loaded (non-cloud mode or error), returns all users.
|
|
119
|
+
*/
|
|
120
|
+
export function filterOnlineUsersByWorkspace(
|
|
121
|
+
onlineUsers: UserPresence[],
|
|
122
|
+
memberUsernames: Set<string>
|
|
123
|
+
): UserPresence[] {
|
|
124
|
+
// If no members loaded, show all users (non-cloud mode fallback)
|
|
125
|
+
if (memberUsernames.size === 0) {
|
|
126
|
+
return onlineUsers;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return onlineUsers.filter((user) =>
|
|
130
|
+
memberUsernames.has(user.username.toLowerCase())
|
|
131
|
+
);
|
|
132
|
+
}
|