@agent-relay/dashboard 2.0.80 → 2.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{AqelRhy1vr2nBUcU0Iqcp → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,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
+ }