@agent-relay/dashboard 2.0.81 → 2.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
  3. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
  4. package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
  5. package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
  6. package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
  7. package/out/about.html +2 -2
  8. package/out/about.txt +1 -1
  9. package/out/app/onboarding.html +1 -1
  10. package/out/app/onboarding.txt +1 -1
  11. package/out/app.html +1 -1
  12. package/out/app.txt +2 -2
  13. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
  14. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  15. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  16. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  17. package/out/blog.html +2 -2
  18. package/out/blog.txt +1 -1
  19. package/out/careers.html +2 -2
  20. package/out/careers.txt +1 -1
  21. package/out/changelog.html +2 -2
  22. package/out/changelog.txt +1 -1
  23. package/out/cloud/link.html +1 -1
  24. package/out/cloud/link.txt +2 -2
  25. package/out/complete-profile.html +2 -2
  26. package/out/complete-profile.txt +1 -1
  27. package/out/connect-repos.html +1 -1
  28. package/out/connect-repos.txt +1 -1
  29. package/out/contact.html +2 -2
  30. package/out/contact.txt +1 -1
  31. package/out/docs.html +2 -2
  32. package/out/docs.txt +1 -1
  33. package/out/history.html +1 -1
  34. package/out/history.txt +2 -2
  35. package/out/index.html +1 -1
  36. package/out/index.txt +2 -2
  37. package/out/login.html +2 -2
  38. package/out/login.txt +1 -1
  39. package/out/metrics.html +1 -1
  40. package/out/metrics.txt +2 -2
  41. package/out/pricing.html +2 -2
  42. package/out/pricing.txt +1 -1
  43. package/out/privacy.html +2 -2
  44. package/out/privacy.txt +1 -1
  45. package/out/providers/setup/claude.html +1 -1
  46. package/out/providers/setup/claude.txt +1 -1
  47. package/out/providers/setup/codex.html +1 -1
  48. package/out/providers/setup/codex.txt +1 -1
  49. package/out/providers/setup/cursor.html +1 -1
  50. package/out/providers/setup/cursor.txt +1 -1
  51. package/out/providers.html +1 -1
  52. package/out/providers.txt +1 -1
  53. package/out/security.html +2 -2
  54. package/out/security.txt +1 -1
  55. package/out/signup.html +2 -2
  56. package/out/signup.txt +1 -1
  57. package/out/terms.html +2 -2
  58. package/out/terms.txt +1 -1
  59. package/package.json +7 -1
  60. package/src/app/about/page.tsx +7 -0
  61. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
  62. package/src/app/app/[[...slug]]/page.tsx +23 -0
  63. package/src/app/app/onboarding/page.tsx +394 -0
  64. package/src/app/apple-icon.png +0 -0
  65. package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
  66. package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
  67. package/src/app/blog/page.tsx +15 -0
  68. package/src/app/careers/page.tsx +7 -0
  69. package/src/app/changelog/page.tsx +7 -0
  70. package/src/app/cloud/link/page.tsx +464 -0
  71. package/src/app/complete-profile/page.tsx +204 -0
  72. package/src/app/connect-repos/page.tsx +410 -0
  73. package/src/app/contact/page.tsx +7 -0
  74. package/src/app/docs/page.tsx +7 -0
  75. package/src/app/favicon.png +0 -0
  76. package/src/app/globals.css +200 -0
  77. package/src/app/history/page.tsx +658 -0
  78. package/src/app/layout.tsx +25 -0
  79. package/src/app/login/page.tsx +424 -0
  80. package/src/app/metrics/page.tsx +781 -0
  81. package/src/app/page.tsx +59 -0
  82. package/src/app/pricing/page.tsx +7 -0
  83. package/src/app/privacy/page.tsx +7 -0
  84. package/src/app/providers/page.tsx +193 -0
  85. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
  86. package/src/app/providers/setup/[provider]/constants.ts +35 -0
  87. package/src/app/providers/setup/[provider]/page.tsx +42 -0
  88. package/src/app/security/page.tsx +7 -0
  89. package/src/app/signup/page.tsx +533 -0
  90. package/src/app/terms/page.tsx +7 -0
  91. package/src/components/ActivityFeed.tsx +216 -0
  92. package/src/components/AddWorkspaceModal.tsx +170 -0
  93. package/src/components/AgentCard.test.tsx +134 -0
  94. package/src/components/AgentCard.tsx +585 -0
  95. package/src/components/AgentList.test.tsx +147 -0
  96. package/src/components/AgentList.tsx +419 -0
  97. package/src/components/AgentLogPreview.tsx +173 -0
  98. package/src/components/AgentProfilePanel.tsx +569 -0
  99. package/src/components/App.tsx +3424 -0
  100. package/src/components/BillingPanel.tsx +922 -0
  101. package/src/components/BillingResult.tsx +447 -0
  102. package/src/components/BroadcastComposer.tsx +690 -0
  103. package/src/components/ChannelAdminPanel.tsx +773 -0
  104. package/src/components/ChannelBrowser.tsx +385 -0
  105. package/src/components/ChannelChat.tsx +261 -0
  106. package/src/components/ChannelSidebar.tsx +399 -0
  107. package/src/components/CloudSessionProvider.tsx +130 -0
  108. package/src/components/CommandPalette.tsx +815 -0
  109. package/src/components/ConfirmationDialog.tsx +133 -0
  110. package/src/components/ConversationHistory.tsx +518 -0
  111. package/src/components/CoordinatorPanel.tsx +956 -0
  112. package/src/components/DecisionQueue.tsx +717 -0
  113. package/src/components/DirectMessageView.tsx +164 -0
  114. package/src/components/FileAutocomplete.tsx +368 -0
  115. package/src/components/FleetOverview.tsx +278 -0
  116. package/src/components/LogViewer.tsx +310 -0
  117. package/src/components/LogViewerPanel.tsx +482 -0
  118. package/src/components/Logo.tsx +284 -0
  119. package/src/components/MentionAutocomplete.tsx +384 -0
  120. package/src/components/MessageComposer.tsx +473 -0
  121. package/src/components/MessageList.tsx +725 -0
  122. package/src/components/MessageSenderName.tsx +91 -0
  123. package/src/components/MessageStatusIndicator.tsx +142 -0
  124. package/src/components/NewConversationModal.tsx +400 -0
  125. package/src/components/NotificationToast.tsx +488 -0
  126. package/src/components/OnlineUsersIndicator.tsx +164 -0
  127. package/src/components/Pagination.tsx +124 -0
  128. package/src/components/PricingPlans.tsx +386 -0
  129. package/src/components/ProjectList.tsx +711 -0
  130. package/src/components/ProviderAuthFlow.tsx +343 -0
  131. package/src/components/ProviderConnectionList.tsx +375 -0
  132. package/src/components/ProvisioningProgress.tsx +730 -0
  133. package/src/components/ReactionChips.tsx +70 -0
  134. package/src/components/ReactionPicker.tsx +121 -0
  135. package/src/components/RepoAccessPanel.tsx +787 -0
  136. package/src/components/RepositoriesPanel.tsx +901 -0
  137. package/src/components/ServerCard.tsx +202 -0
  138. package/src/components/SessionExpiredModal.tsx +128 -0
  139. package/src/components/SpawnModal.test.tsx +190 -0
  140. package/src/components/SpawnModal.tsx +1001 -0
  141. package/src/components/TaskAssignmentUI.tsx +375 -0
  142. package/src/components/TerminalProviderSetup.tsx +517 -0
  143. package/src/components/ThemeProvider.tsx +159 -0
  144. package/src/components/ThinkingIndicator.tsx +231 -0
  145. package/src/components/ThreadList.tsx +198 -0
  146. package/src/components/ThreadPanel.tsx +405 -0
  147. package/src/components/TrajectoryViewer.tsx +698 -0
  148. package/src/components/TypingIndicator.tsx +69 -0
  149. package/src/components/UsageBanner.tsx +231 -0
  150. package/src/components/UserProfilePanel.tsx +233 -0
  151. package/src/components/WorkspaceContext.tsx +95 -0
  152. package/src/components/WorkspaceSelector.tsx +234 -0
  153. package/src/components/WorkspaceStatusIndicator.tsx +396 -0
  154. package/src/components/XTermInteractive.tsx +516 -0
  155. package/src/components/XTermLogViewer.tsx +719 -0
  156. package/src/components/channels/ChannelDialogs.tsx +1411 -0
  157. package/src/components/channels/ChannelHeader.tsx +317 -0
  158. package/src/components/channels/ChannelMessageList.tsx +463 -0
  159. package/src/components/channels/ChannelViewV1.tsx +146 -0
  160. package/src/components/channels/MessageInput.tsx +302 -0
  161. package/src/components/channels/SearchInput.tsx +172 -0
  162. package/src/components/channels/SearchResults.tsx +336 -0
  163. package/src/components/channels/api.test.ts +1527 -0
  164. package/src/components/channels/api.ts +703 -0
  165. package/src/components/channels/index.ts +76 -0
  166. package/src/components/channels/mockApi.ts +344 -0
  167. package/src/components/channels/types.ts +566 -0
  168. package/src/components/hooks/index.ts +58 -0
  169. package/src/components/hooks/useAgentLogs.ts +504 -0
  170. package/src/components/hooks/useAgents.ts +127 -0
  171. package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
  172. package/src/components/hooks/useBroadcastDedup.ts +86 -0
  173. package/src/components/hooks/useChannelAdmin.ts +329 -0
  174. package/src/components/hooks/useChannelBrowser.ts +239 -0
  175. package/src/components/hooks/useChannelCommands.ts +138 -0
  176. package/src/components/hooks/useChannels.ts +367 -0
  177. package/src/components/hooks/useDebounce.ts +29 -0
  178. package/src/components/hooks/useDirectMessage.test.ts +952 -0
  179. package/src/components/hooks/useDirectMessage.ts +141 -0
  180. package/src/components/hooks/useMessages.ts +310 -0
  181. package/src/components/hooks/useOrchestrator.test.ts +165 -0
  182. package/src/components/hooks/useOrchestrator.ts +424 -0
  183. package/src/components/hooks/usePinnedAgents.test.ts +356 -0
  184. package/src/components/hooks/usePinnedAgents.ts +140 -0
  185. package/src/components/hooks/usePresence.test.ts +245 -0
  186. package/src/components/hooks/usePresence.ts +377 -0
  187. package/src/components/hooks/useRecentRepos.ts +130 -0
  188. package/src/components/hooks/useSession.ts +209 -0
  189. package/src/components/hooks/useThread.ts +138 -0
  190. package/src/components/hooks/useTrajectory.ts +265 -0
  191. package/src/components/hooks/useWebSocket.ts +290 -0
  192. package/src/components/hooks/useWorkspaceMembers.ts +132 -0
  193. package/src/components/hooks/useWorkspaceRepos.ts +73 -0
  194. package/src/components/hooks/useWorkspaceStatus.ts +237 -0
  195. package/src/components/index.ts +81 -0
  196. package/src/components/layout/Header.tsx +311 -0
  197. package/src/components/layout/RepoContextHeader.tsx +361 -0
  198. package/src/components/layout/Sidebar.archive.test.tsx +126 -0
  199. package/src/components/layout/Sidebar.test.tsx +691 -0
  200. package/src/components/layout/Sidebar.tsx +900 -0
  201. package/src/components/layout/index.ts +7 -0
  202. package/src/components/settings/BillingSettingsPanel.tsx +564 -0
  203. package/src/components/settings/SettingsPage.tsx +683 -0
  204. package/src/components/settings/TeamSettingsPanel.tsx +560 -0
  205. package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
  206. package/src/components/settings/index.ts +11 -0
  207. package/src/components/settings/types.ts +79 -0
  208. package/src/components/utils/messageFormatting.test.tsx +331 -0
  209. package/src/components/utils/messageFormatting.tsx +597 -0
  210. package/src/index.ts +63 -0
  211. package/src/landing/AboutPage.tsx +77 -0
  212. package/src/landing/BlogContent.tsx +187 -0
  213. package/src/landing/BlogPage.tsx +47 -0
  214. package/src/landing/CareersPage.tsx +53 -0
  215. package/src/landing/ChangelogPage.tsx +33 -0
  216. package/src/landing/ContactPage.tsx +41 -0
  217. package/src/landing/DocsPage.tsx +43 -0
  218. package/src/landing/LandingPage.tsx +702 -0
  219. package/src/landing/PricingPage.tsx +549 -0
  220. package/src/landing/PrivacyPage.tsx +117 -0
  221. package/src/landing/SecurityPage.tsx +42 -0
  222. package/src/landing/StaticPage.tsx +165 -0
  223. package/src/landing/TermsPage.tsx +125 -0
  224. package/src/landing/blogData.ts +312 -0
  225. package/src/landing/index.ts +18 -0
  226. package/src/landing/styles.css +3673 -0
  227. package/src/lib/agent-merge.test.ts +43 -0
  228. package/src/lib/agent-merge.ts +35 -0
  229. package/src/lib/api.ts +1294 -0
  230. package/src/lib/cloudApi.ts +893 -0
  231. package/src/lib/colors.test.ts +175 -0
  232. package/src/lib/colors.ts +218 -0
  233. package/src/lib/config.ts +109 -0
  234. package/src/lib/hierarchy.ts +242 -0
  235. package/src/lib/stuckDetection.ts +142 -0
  236. package/src/lib/useUrlRouting.ts +190 -0
  237. package/src/types/index.ts +317 -0
  238. package/src/types/threading.ts +7 -0
  239. package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
  240. package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
  241. /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
  242. /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
  243. /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
  244. /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * useAgentLogs Hook
3
+ *
4
+ * React hook for streaming live PTY output from agents via WebSocket.
5
+ * Connects to the agent log streaming endpoint and provides real-time updates.
6
+ *
7
+ * Supports log replay on reconnect: tracks the last received timestamp and
8
+ * requests missed log entries from the server after reconnecting.
9
+ */
10
+
11
+ import { useState, useEffect, useCallback, useRef } from 'react';
12
+ import { useWorkspaceWsUrl } from '../WorkspaceContext';
13
+
14
+ export interface LogLine {
15
+ id: string;
16
+ timestamp: number;
17
+ content: string;
18
+ type: 'stdout' | 'stderr' | 'system' | 'input';
19
+ agentName?: string;
20
+ }
21
+
22
+ export interface UseAgentLogsOptions {
23
+ agentName: string;
24
+ /** Maximum number of lines to keep in buffer */
25
+ maxLines?: number;
26
+ /** Auto-connect on mount */
27
+ autoConnect?: boolean;
28
+ /** Enable reconnection on disconnect */
29
+ reconnect?: boolean;
30
+ /** Maximum reconnection attempts */
31
+ maxReconnectAttempts?: number;
32
+ }
33
+
34
+ /** Connection quality state for UI indicators */
35
+ export type LogConnectionState = 'connected' | 'reconnecting' | 'disconnected';
36
+
37
+ export interface UseAgentLogsReturn {
38
+ logs: LogLine[];
39
+ isConnected: boolean;
40
+ isConnecting: boolean;
41
+ /** Granular connection quality: 'connected', 'reconnecting', or 'disconnected' */
42
+ connectionState: LogConnectionState;
43
+ error: Error | null;
44
+ connect: () => void;
45
+ disconnect: () => void;
46
+ clear: () => void;
47
+ }
48
+
49
+ /**
50
+ * Generate a unique ID for log lines
51
+ */
52
+ let logIdCounter = 0;
53
+ function generateLogId(): string {
54
+ return `log-${Date.now()}-${++logIdCounter}`;
55
+ }
56
+
57
+ export function useAgentLogs(options: UseAgentLogsOptions): UseAgentLogsReturn {
58
+ const {
59
+ agentName,
60
+ maxLines = 5000,
61
+ autoConnect = true,
62
+ reconnect = true,
63
+ maxReconnectAttempts = Infinity,
64
+ } = options;
65
+
66
+ const logStreamUrl = useWorkspaceWsUrl(`/ws/logs/${encodeURIComponent(agentName)}`);
67
+
68
+ const [logs, setLogs] = useState<LogLine[]>([]);
69
+ const [isConnected, setIsConnected] = useState(false);
70
+ const [isConnecting, setIsConnecting] = useState(false);
71
+ const [connectionState, setConnectionState] = useState<LogConnectionState>('disconnected');
72
+ const [error, setError] = useState<Error | null>(null);
73
+
74
+ const wsRef = useRef<WebSocket | null>(null);
75
+ const reconnectAttemptsRef = useRef(0);
76
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
77
+ const agentNameRef = useRef(agentName);
78
+ const shouldReconnectRef = useRef(true);
79
+ const isConnectingRef = useRef(false);
80
+ // Track manual close state per-WebSocket instance to avoid race conditions
81
+ // when React remounts quickly (e.g., StrictMode). Using WeakMap ensures
82
+ // each WebSocket tracks its own "was this a manual close" state.
83
+ const manualCloseMapRef = useRef(new WeakMap<WebSocket, boolean>());
84
+ // Track if we've successfully received data per-WebSocket instance
85
+ const hasReceivedDataMapRef = useRef(new WeakMap<WebSocket, boolean>());
86
+
87
+ // Replay support: track last received timestamp and known content for dedup
88
+ const lastTimestampRef = useRef<number | null>(null);
89
+ const hasConnectedBeforeRef = useRef(false);
90
+ // Track recent log content hashes for deduplication during replay
91
+ const recentLogHashesRef = useRef(new Set<string>());
92
+
93
+ // Keep agent name ref updated
94
+ agentNameRef.current = agentName;
95
+
96
+ /**
97
+ * Generate a simple hash for a log line to detect duplicates.
98
+ */
99
+ const logHash = useCallback((content: string, timestamp: number): string => {
100
+ return `${timestamp}:${content.slice(0, 100)}`;
101
+ }, []);
102
+
103
+ const connect = useCallback(() => {
104
+ // Ensure reconnects are allowed for this session
105
+ shouldReconnectRef.current = true;
106
+
107
+ // Prevent multiple connections - use ref to avoid dependency on state
108
+ if (wsRef.current?.readyState === WebSocket.OPEN ||
109
+ wsRef.current?.readyState === WebSocket.CONNECTING ||
110
+ isConnectingRef.current) {
111
+ return;
112
+ }
113
+
114
+ // Track reconnection state
115
+ if (hasConnectedBeforeRef.current) {
116
+ setConnectionState('reconnecting');
117
+ }
118
+
119
+ isConnectingRef.current = true;
120
+ setIsConnecting(true);
121
+ setError(null);
122
+
123
+ try {
124
+ const ws = new WebSocket(logStreamUrl);
125
+ wsRef.current = ws;
126
+ // Initialize per-WebSocket state
127
+ manualCloseMapRef.current.set(ws, false);
128
+ hasReceivedDataMapRef.current.set(ws, false);
129
+
130
+ ws.onopen = () => {
131
+ isConnectingRef.current = false;
132
+ setIsConnected(true);
133
+ setIsConnecting(false);
134
+ setConnectionState('connected');
135
+ setError(null);
136
+ reconnectAttemptsRef.current = 0;
137
+
138
+ // On reconnect, request replay of missed log entries
139
+ if (hasConnectedBeforeRef.current && lastTimestampRef.current !== null) {
140
+ console.log(`[WS:Logs] Requesting replay from timestamp ${lastTimestampRef.current}`);
141
+ ws.send(JSON.stringify({
142
+ type: 'replay',
143
+ agent: agentNameRef.current,
144
+ lastTimestamp: lastTimestampRef.current,
145
+ }));
146
+ }
147
+
148
+ hasConnectedBeforeRef.current = true;
149
+
150
+ // Add system message for connection
151
+ setLogs((prev) => [
152
+ ...prev,
153
+ {
154
+ id: generateLogId(),
155
+ timestamp: Date.now(),
156
+ content: `Connected to ${agentNameRef.current} log stream`,
157
+ type: 'system',
158
+ agentName: agentNameRef.current,
159
+ },
160
+ ]);
161
+ };
162
+
163
+ ws.onclose = (event) => {
164
+ // Read per-WebSocket state (isolated from other connections)
165
+ const wasManualClose = manualCloseMapRef.current.get(ws) ?? false;
166
+ const hadReceivedData = hasReceivedDataMapRef.current.get(ws) ?? false;
167
+
168
+ isConnectingRef.current = false;
169
+ setIsConnected(false);
170
+ setIsConnecting(false);
171
+ wsRef.current = null;
172
+
173
+ // Clear any pending reconnect when a close happens
174
+ if (reconnectTimeoutRef.current) {
175
+ clearTimeout(reconnectTimeoutRef.current);
176
+ reconnectTimeoutRef.current = null;
177
+ }
178
+
179
+ // Skip logging/reconnecting for intentional disconnects (cleanup, user toggle)
180
+ if (wasManualClose) {
181
+ setConnectionState('disconnected');
182
+ return;
183
+ }
184
+
185
+ // Don't reconnect if agent was not found (custom close code 4404)
186
+ // This prevents infinite reconnect loops for non-existent agents
187
+ if (event.code === 4404) {
188
+ setConnectionState('disconnected');
189
+ return;
190
+ }
191
+
192
+ // Add system message for disconnection, but only if:
193
+ // 1. The close was not clean (code 1006 or similar)
194
+ // 2. We had actually received data (to avoid false positives from transient connection issues)
195
+ // Code 1006 is very common and happens during normal operations (React remounts,
196
+ // network hiccups, etc.) - only show error if we had an established data stream
197
+ if (!event.wasClean && hadReceivedData) {
198
+ const willReconnect =
199
+ shouldReconnectRef.current &&
200
+ reconnect &&
201
+ reconnectAttemptsRef.current < maxReconnectAttempts;
202
+
203
+ setLogs((prev) => [
204
+ ...prev,
205
+ {
206
+ id: generateLogId(),
207
+ timestamp: Date.now(),
208
+ content: willReconnect
209
+ ? `Lost connection to log stream (code: ${event.code}). Reconnecting...`
210
+ : `Disconnected from log stream (code: ${event.code})`,
211
+ type: 'system',
212
+ agentName: agentNameRef.current,
213
+ },
214
+ ]);
215
+ }
216
+
217
+ // Schedule reconnect if enabled
218
+ if (
219
+ shouldReconnectRef.current &&
220
+ reconnect &&
221
+ reconnectAttemptsRef.current < maxReconnectAttempts
222
+ ) {
223
+ setConnectionState('reconnecting');
224
+ const baseDelay = Math.min(
225
+ 500 * Math.pow(2, reconnectAttemptsRef.current),
226
+ 15000
227
+ );
228
+ // Add jitter to prevent thundering herd
229
+ const delay = Math.round(baseDelay * (0.5 + Math.random() * 0.5));
230
+ reconnectAttemptsRef.current++;
231
+
232
+ console.log(`[WS:Logs] Reconnecting (attempt ${reconnectAttemptsRef.current})...`);
233
+
234
+ reconnectTimeoutRef.current = setTimeout(() => {
235
+ connect();
236
+ }, delay);
237
+ } else {
238
+ setConnectionState('disconnected');
239
+ }
240
+ };
241
+
242
+ ws.onerror = () => {
243
+ isConnectingRef.current = false;
244
+ setError(new Error('WebSocket connection error'));
245
+ setIsConnecting(false);
246
+ };
247
+
248
+ ws.onmessage = (event) => {
249
+ try {
250
+ const data = JSON.parse(event.data);
251
+
252
+ // Handle error messages from server
253
+ if (data.type === 'error') {
254
+ setError(new Error(data.error || `Failed to stream logs for ${data.agent || agentNameRef.current}`));
255
+ setLogs((prev) => [
256
+ ...prev,
257
+ {
258
+ id: generateLogId(),
259
+ timestamp: Date.now(),
260
+ content: `Error: ${data.error || 'Unknown error'}`,
261
+ type: 'system',
262
+ agentName: data.agent || agentNameRef.current,
263
+ },
264
+ ]);
265
+ return;
266
+ }
267
+
268
+ // Handle subscribed confirmation
269
+ if (data.type === 'subscribed') {
270
+ console.log(`[useAgentLogs] Subscribed to ${data.agent}`);
271
+ return;
272
+ }
273
+
274
+ // Handle replay response: array of missed log entries
275
+ if (data.type === 'replay' && Array.isArray(data.entries)) {
276
+ hasReceivedDataMapRef.current.set(ws, true);
277
+ setLogs((prev) => {
278
+ const replayLines: LogLine[] = [];
279
+ for (const entry of data.entries) {
280
+ const content = entry.content || '';
281
+ const timestamp = entry.timestamp || Date.now();
282
+ const hash = logHash(content, timestamp);
283
+
284
+ // Skip duplicates
285
+ if (recentLogHashesRef.current.has(hash)) {
286
+ continue;
287
+ }
288
+ recentLogHashesRef.current.add(hash);
289
+
290
+ replayLines.push({
291
+ id: generateLogId(),
292
+ timestamp,
293
+ content,
294
+ type: 'stdout' as const,
295
+ agentName: agentNameRef.current,
296
+ });
297
+
298
+ // Update last timestamp
299
+ if (timestamp > (lastTimestampRef.current ?? 0)) {
300
+ lastTimestampRef.current = timestamp;
301
+ }
302
+ }
303
+
304
+ if (replayLines.length === 0) return prev;
305
+ return [...prev, ...replayLines].slice(-maxLines);
306
+ });
307
+ return;
308
+ }
309
+
310
+ // Handle history (initial log dump)
311
+ if (data.type === 'history' && Array.isArray(data.lines)) {
312
+ // Mark as having received data - connection is established
313
+ if (data.lines.length > 0) {
314
+ hasReceivedDataMapRef.current.set(ws, true);
315
+ }
316
+ setLogs((prev) => {
317
+ const historyLines: LogLine[] = data.lines.map((line: string) => {
318
+ const ts = Date.now();
319
+ const hash = logHash(line, ts);
320
+ recentLogHashesRef.current.add(hash);
321
+ lastTimestampRef.current = ts;
322
+ return {
323
+ id: generateLogId(),
324
+ timestamp: ts,
325
+ content: line,
326
+ type: 'stdout' as const,
327
+ agentName: data.agent || agentNameRef.current,
328
+ };
329
+ });
330
+ return [...prev, ...historyLines].slice(-maxLines);
331
+ });
332
+ return;
333
+ }
334
+
335
+ // Handle different message formats - mark as having received data for all actual log messages
336
+ if (typeof data === 'string') {
337
+ // Simple string message
338
+ hasReceivedDataMapRef.current.set(ws, true);
339
+ const ts = Date.now();
340
+ lastTimestampRef.current = ts;
341
+ const hash = logHash(data, ts);
342
+ recentLogHashesRef.current.add(hash);
343
+ setLogs((prev) => {
344
+ const newLogs = [
345
+ ...prev,
346
+ {
347
+ id: generateLogId(),
348
+ timestamp: ts,
349
+ content: data,
350
+ type: 'stdout' as const,
351
+ agentName: agentNameRef.current,
352
+ },
353
+ ];
354
+ return newLogs.slice(-maxLines);
355
+ });
356
+ } else if (data.type === 'log' || data.type === 'output') {
357
+ // Structured log message
358
+ hasReceivedDataMapRef.current.set(ws, true);
359
+ const ts = data.timestamp || Date.now();
360
+ const content = data.content || data.data || data.message || '';
361
+ lastTimestampRef.current = ts;
362
+ const hash = logHash(content, ts);
363
+ recentLogHashesRef.current.add(hash);
364
+ setLogs((prev) => {
365
+ const logType: LogLine['type'] = data.stream === 'stderr' ? 'stderr' : 'stdout';
366
+ const newLogs: LogLine[] = [
367
+ ...prev,
368
+ {
369
+ id: generateLogId(),
370
+ timestamp: ts,
371
+ content,
372
+ type: logType,
373
+ agentName: data.agentName || agentNameRef.current,
374
+ },
375
+ ];
376
+ return newLogs.slice(-maxLines);
377
+ });
378
+ } else if (data.lines && Array.isArray(data.lines)) {
379
+ // Batch of lines
380
+ hasReceivedDataMapRef.current.set(ws, true);
381
+ setLogs((prev) => {
382
+ const newLines: LogLine[] = data.lines.map((line: string | { content: string; type?: string }) => {
383
+ const lineType: LogLine['type'] = (typeof line === 'object' && line.type === 'stderr') ? 'stderr' : 'stdout';
384
+ const content = typeof line === 'string' ? line : line.content;
385
+ const ts = Date.now();
386
+ lastTimestampRef.current = ts;
387
+ const hash = logHash(content, ts);
388
+ recentLogHashesRef.current.add(hash);
389
+ return {
390
+ id: generateLogId(),
391
+ timestamp: ts,
392
+ content,
393
+ type: lineType,
394
+ agentName: agentNameRef.current,
395
+ };
396
+ });
397
+ return [...prev, ...newLines].slice(-maxLines);
398
+ });
399
+ }
400
+
401
+ // Keep the dedup set from growing unbounded
402
+ if (recentLogHashesRef.current.size > 2000) {
403
+ const entries = Array.from(recentLogHashesRef.current);
404
+ recentLogHashesRef.current = new Set(entries.slice(-1000));
405
+ }
406
+ } catch {
407
+ // Handle plain text messages
408
+ if (typeof event.data === 'string') {
409
+ hasReceivedDataMapRef.current.set(ws, true);
410
+ const ts = Date.now();
411
+ lastTimestampRef.current = ts;
412
+ setLogs((prev) => {
413
+ const newLogs = [
414
+ ...prev,
415
+ {
416
+ id: generateLogId(),
417
+ timestamp: ts,
418
+ content: event.data,
419
+ type: 'stdout' as const,
420
+ agentName: agentNameRef.current,
421
+ },
422
+ ];
423
+ return newLogs.slice(-maxLines);
424
+ });
425
+ }
426
+ }
427
+ };
428
+ } catch (e) {
429
+ isConnectingRef.current = false;
430
+ setError(e instanceof Error ? e : new Error('Failed to create WebSocket'));
431
+ setIsConnecting(false);
432
+ }
433
+ }, [logStreamUrl, maxLines, reconnect, maxReconnectAttempts, logHash]);
434
+
435
+ const disconnect = useCallback(() => {
436
+ // Prevent reconnection attempts after an intentional disconnect
437
+ shouldReconnectRef.current = false;
438
+
439
+ // Clear any pending reconnect
440
+ if (reconnectTimeoutRef.current) {
441
+ clearTimeout(reconnectTimeoutRef.current);
442
+ reconnectTimeoutRef.current = null;
443
+ }
444
+
445
+ // Mark this WebSocket as manually closed before closing it
446
+ // This prevents the false positive error message on close
447
+ if (wsRef.current) {
448
+ manualCloseMapRef.current.set(wsRef.current, true);
449
+ wsRef.current.close();
450
+ wsRef.current = null;
451
+ }
452
+
453
+ isConnectingRef.current = false;
454
+ setIsConnected(false);
455
+ setIsConnecting(false);
456
+ setConnectionState('disconnected');
457
+ }, []);
458
+
459
+ const clear = useCallback(() => {
460
+ setLogs([]);
461
+ recentLogHashesRef.current.clear();
462
+ }, []);
463
+
464
+ // Auto-connect on mount or agent change
465
+ useEffect(() => {
466
+ if (autoConnect && agentName) {
467
+ connect();
468
+ }
469
+
470
+ return () => {
471
+ disconnect();
472
+ };
473
+ }, [agentName, autoConnect, connect, disconnect]);
474
+
475
+ // Visibility change listener: reconnect when tab becomes visible
476
+ useEffect(() => {
477
+ const handleVisibilityChange = () => {
478
+ if (document.visibilityState === 'visible') {
479
+ // Check if connection is dead and reconnect
480
+ if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
481
+ console.log('[WS:Logs] Tab visible, reconnecting...');
482
+ reconnectAttemptsRef.current = 0; // Reset attempts for visibility-triggered reconnect
483
+ connect();
484
+ }
485
+ }
486
+ };
487
+
488
+ document.addEventListener('visibilitychange', handleVisibilityChange);
489
+ return () => {
490
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
491
+ };
492
+ }, [connect]);
493
+
494
+ return {
495
+ logs,
496
+ isConnected,
497
+ isConnecting,
498
+ connectionState,
499
+ error,
500
+ connect,
501
+ disconnect,
502
+ clear,
503
+ };
504
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * useAgents Hook
3
+ *
4
+ * React hook for managing agent state with hierarchical grouping,
5
+ * filtering, and selection.
6
+ */
7
+
8
+ import { useState, useMemo, useCallback } from 'react';
9
+ import type { Agent } from '../../types';
10
+ import {
11
+ groupAgents,
12
+ filterAgents,
13
+ sortAgentsByHierarchy,
14
+ getGroupStats,
15
+ type AgentGroup,
16
+ } from '../../lib/hierarchy';
17
+ import { getAgentColor, type ColorScheme } from '../../lib/colors';
18
+
19
+ export interface UseAgentsOptions {
20
+ agents: Agent[];
21
+ initialSelected?: string;
22
+ initialSearchQuery?: string;
23
+ }
24
+
25
+ export interface AgentWithColor extends Agent {
26
+ color: ColorScheme;
27
+ }
28
+
29
+ export interface UseAgentsReturn {
30
+ // Filtered and grouped agents
31
+ agents: Agent[];
32
+ groups: AgentGroup[];
33
+ sortedAgents: Agent[];
34
+
35
+ // Selection
36
+ selectedAgent: Agent | null;
37
+ selectAgent: (name: string | null) => void;
38
+
39
+ // Search/filter
40
+ searchQuery: string;
41
+ setSearchQuery: (query: string) => void;
42
+
43
+ // Stats
44
+ totalCount: number;
45
+ onlineCount: number;
46
+ needsAttentionCount: number;
47
+
48
+ // Utilities
49
+ getAgentByName: (name: string) => Agent | undefined;
50
+ getAgentWithColor: (agent: Agent) => AgentWithColor;
51
+ }
52
+
53
+ export function useAgents({
54
+ agents,
55
+ initialSelected,
56
+ initialSearchQuery = '',
57
+ }: UseAgentsOptions): UseAgentsReturn {
58
+ const [selectedName, setSelectedName] = useState<string | null>(initialSelected ?? null);
59
+ const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
60
+
61
+ // Filter agents by search query
62
+ const filteredAgents = useMemo(
63
+ () => filterAgents(agents, searchQuery),
64
+ [agents, searchQuery]
65
+ );
66
+
67
+ // Group agents by prefix
68
+ const groups = useMemo(
69
+ () => groupAgents(filteredAgents),
70
+ [filteredAgents]
71
+ );
72
+
73
+ // Sort agents for flat list display
74
+ const sortedAgents = useMemo(
75
+ () => sortAgentsByHierarchy(filteredAgents),
76
+ [filteredAgents]
77
+ );
78
+
79
+ // Get selected agent object
80
+ const selectedAgent = useMemo(
81
+ () => agents.find((a) => a.name === selectedName) ?? null,
82
+ [agents, selectedName]
83
+ );
84
+
85
+ // Calculate stats
86
+ const stats = useMemo(() => {
87
+ const allStats = getGroupStats(agents);
88
+ return {
89
+ totalCount: allStats.total,
90
+ onlineCount: allStats.online,
91
+ needsAttentionCount: allStats.needsAttention,
92
+ };
93
+ }, [agents]);
94
+
95
+ // Selection handler
96
+ const selectAgent = useCallback((name: string | null) => {
97
+ setSelectedName(name);
98
+ }, []);
99
+
100
+ // Get agent by name
101
+ const getAgentByName = useCallback(
102
+ (name: string) => agents.find((a) => a.name === name),
103
+ [agents]
104
+ );
105
+
106
+ // Get agent with color scheme attached
107
+ const getAgentWithColor = useCallback(
108
+ (agent: Agent): AgentWithColor => ({
109
+ ...agent,
110
+ color: getAgentColor(agent.name),
111
+ }),
112
+ []
113
+ );
114
+
115
+ return {
116
+ agents: filteredAgents,
117
+ groups,
118
+ sortedAgents,
119
+ selectedAgent,
120
+ selectAgent,
121
+ searchQuery,
122
+ setSearchQuery,
123
+ ...stats,
124
+ getAgentByName,
125
+ getAgentWithColor,
126
+ };
127
+ }