@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,141 @@
1
+ /**
2
+ * useDirectMessage Hook
3
+ *
4
+ * Handles DM conversation logic including agent participation,
5
+ * message filtering, and deduplication.
6
+ */
7
+
8
+ import { useMemo } from 'react';
9
+ import type { Agent, Message } from '../../types';
10
+
11
+ export interface UseDirectMessageOptions {
12
+ currentHuman: Agent | null;
13
+ currentUserName: string | null;
14
+ messages: Message[];
15
+ agents: Agent[];
16
+ selectedDmAgents: string[];
17
+ removedDmAgents: string[];
18
+ }
19
+
20
+ export interface UseDirectMessageResult {
21
+ visibleMessages: Message[];
22
+ participantAgents: string[];
23
+ }
24
+
25
+ export function useDirectMessage({
26
+ currentHuman,
27
+ currentUserName,
28
+ messages,
29
+ agents,
30
+ selectedDmAgents,
31
+ removedDmAgents,
32
+ }: UseDirectMessageOptions): UseDirectMessageResult {
33
+ const agentNameSet = useMemo(() => new Set(agents.map((a) => a.name)), [agents]);
34
+
35
+ // Derive agents participating in this conversation from message history
36
+ const dmParticipantAgents = useMemo(() => {
37
+ if (!currentHuman) return [];
38
+ const humanName = currentHuman.name;
39
+ const derived = new Set<string>();
40
+
41
+ for (const msg of messages) {
42
+ const { from, to } = msg;
43
+ if (!from || !to) continue;
44
+ if (from === humanName && agentNameSet.has(to)) derived.add(to);
45
+ if (to === humanName && agentNameSet.has(from)) derived.add(from);
46
+ if (selectedDmAgents.includes(from) && agentNameSet.has(to)) derived.add(to);
47
+ if (selectedDmAgents.includes(to) && agentNameSet.has(from)) derived.add(from);
48
+ }
49
+
50
+ const participants = new Set<string>([...selectedDmAgents, ...derived]);
51
+ removedDmAgents.forEach((a) => participants.delete(a));
52
+ return Array.from(participants);
53
+ }, [agentNameSet, currentHuman, messages, removedDmAgents, selectedDmAgents]);
54
+
55
+ // Filter messages for this DM conversation
56
+ const visibleMessages = useMemo(() => {
57
+ if (!currentHuman) return messages;
58
+ // Include current user, the other human, and all participant agents
59
+ const participants = new Set<string>([currentHuman.name, ...dmParticipantAgents]);
60
+ // Add current user to participants - use "Dashboard" as fallback for local mode
61
+ const effectiveUserName = currentUserName || 'Dashboard';
62
+ participants.add(effectiveUserName);
63
+
64
+ console.log('[DM Filter] currentHuman:', currentHuman.name, 'currentUser:', currentUserName, 'agents:', dmParticipantAgents, 'participants:', Array.from(participants));
65
+
66
+ const filtered = messages.filter((msg) => {
67
+ if (!msg.from || !msg.to) return false;
68
+ const hasFrom = participants.has(msg.from);
69
+ const hasTo = participants.has(msg.to);
70
+ const passes = hasFrom && hasTo;
71
+
72
+ if (msg.from?.includes('Agent') || msg.to?.includes('Agent')) {
73
+ console.log('[DM Filter] msg:', msg.from, '->', msg.to, 'hasFrom:', hasFrom, 'hasTo:', hasTo, 'passes:', passes);
74
+ }
75
+
76
+ return passes;
77
+ });
78
+
79
+ console.log('[DM Filter] filtered count:', filtered.length);
80
+ return filtered;
81
+ }, [currentHuman, currentUserName, dmParticipantAgents, messages]);
82
+
83
+ // Deduplicate DM messages (merge duplicates sent to multiple participants)
84
+ const dedupedVisibleMessages = useMemo(() => {
85
+ if (!currentHuman) return visibleMessages;
86
+
87
+ const normalizeBody = (content?: string) => (content ?? '').trim().replace(/\s+/g, ' ');
88
+ const rank = (msg: Message) => (msg.status === 'sending' ? 1 : 0);
89
+ const choose = (current: Message, incoming: Message) => {
90
+ const currentRank = rank(current);
91
+ const incomingRank = rank(incoming);
92
+ const currentTs = new Date(current.timestamp).getTime();
93
+ const incomingTs = new Date(incoming.timestamp).getTime();
94
+ if (incomingRank < currentRank) return incoming;
95
+ if (incomingRank > currentRank) return current;
96
+ return incomingTs >= currentTs ? incoming : current;
97
+ };
98
+
99
+ const sorted = [...visibleMessages].sort(
100
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
101
+ );
102
+
103
+ const byId = new Map<string, Message>();
104
+ const byFuzzy = new Map<string, Message>();
105
+
106
+ for (const msg of sorted) {
107
+ if (msg.id) {
108
+ const existing = byId.get(msg.id);
109
+ byId.set(msg.id, existing ? choose(existing, msg) : msg);
110
+ continue;
111
+ }
112
+
113
+ const sender = msg.from?.toLowerCase() ?? '';
114
+ const bucket = Math.floor(new Date(msg.timestamp).getTime() / 5000);
115
+ const key = `${sender}|${bucket}|${normalizeBody(msg.content)}`;
116
+ const existing = byFuzzy.get(key);
117
+ byFuzzy.set(key, existing ? choose(existing, msg) : msg);
118
+ }
119
+
120
+ const merged = [...byId.values(), ...byFuzzy.values()];
121
+
122
+ // Final pass: deduplicate by sender + recipient + content (no time bucket)
123
+ const finalDedup = new Map<string, Message>();
124
+ for (const msg of merged) {
125
+ const sender = msg.from?.toLowerCase() ?? '';
126
+ const recipient = msg.to?.toLowerCase() ?? '';
127
+ const key = `${sender}|${recipient}|${normalizeBody(msg.content)}`;
128
+ const existing = finalDedup.get(key);
129
+ finalDedup.set(key, existing ? choose(existing, msg) : msg);
130
+ }
131
+
132
+ return Array.from(finalDedup.values()).sort(
133
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
134
+ );
135
+ }, [currentHuman, visibleMessages]);
136
+
137
+ return {
138
+ visibleMessages: dedupedVisibleMessages,
139
+ participantAgents: dmParticipantAgents,
140
+ };
141
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * useMessages Hook
3
+ *
4
+ * React hook for managing message state with filtering,
5
+ * threading, and send functionality.
6
+ */
7
+
8
+ import { useState, useMemo, useCallback, useEffect } from 'react';
9
+ import type { Message, SendMessageRequest } from '../../types';
10
+ import { api } from '../../lib/api';
11
+
12
+ export interface UseMessagesOptions {
13
+ messages: Message[];
14
+ currentChannel?: string;
15
+ /** Optional sender name for cloud mode (GitHub username). Falls back to 'Dashboard' if not provided. */
16
+ senderName?: string;
17
+ }
18
+
19
+ export interface ThreadInfo {
20
+ id: string;
21
+ name: string;
22
+ lastMessage: Message;
23
+ messageCount: number;
24
+ unreadCount: number;
25
+ participants: string[];
26
+ }
27
+
28
+ export interface UseMessagesReturn {
29
+ // Filtered messages
30
+ messages: Message[];
31
+ threadMessages: (threadId: string) => Message[];
32
+
33
+ // Channel/thread state
34
+ currentChannel: string;
35
+ setCurrentChannel: (channel: string) => void;
36
+ currentThread: string | null;
37
+ setCurrentThread: (threadId: string | null) => void;
38
+
39
+ // Thread info
40
+ activeThreads: ThreadInfo[];
41
+ totalUnreadThreadCount: number;
42
+
43
+ // Message actions
44
+ sendMessage: (to: string, content: string, thread?: string, attachmentIds?: string[]) => Promise<boolean>;
45
+ isSending: boolean;
46
+ sendError: string | null;
47
+
48
+ // Stats
49
+ totalCount: number;
50
+ unreadCount: number;
51
+ }
52
+
53
+ export function useMessages({
54
+ messages,
55
+ currentChannel: initialChannel = 'general',
56
+ senderName,
57
+ }: UseMessagesOptions): UseMessagesReturn {
58
+ const [currentChannel, setCurrentChannel] = useState(initialChannel);
59
+ const [currentThreadInternal, setCurrentThreadInternal] = useState<string | null>(null);
60
+ const [isSending, setIsSending] = useState(false);
61
+ const [sendError, setSendError] = useState<string | null>(null);
62
+
63
+ // Track seen threads with timestamp of when they were last viewed
64
+ // This allows us to show new messages that arrive after viewing
65
+ const [seenThreads, setSeenThreads] = useState<Map<string, number>>(new Map());
66
+
67
+ // Effective sender name for the current user (used for filtering own messages)
68
+ const effectiveSenderName = senderName || 'Dashboard';
69
+
70
+ // Optimistic messages: shown immediately before server confirms
71
+ // These have status='sending' and a temp ID prefixed with 'optimistic-'
72
+ const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
73
+
74
+ // Clean up optimistic messages when they appear in the real messages list
75
+ // Match by to + content only (not from, since server may use different sender like 'Dashboard')
76
+ useEffect(() => {
77
+ if (optimisticMessages.length === 0) return;
78
+
79
+ // Create a set of "fingerprints" for real messages (recent ones only)
80
+ // Use to + content only - the 'from' field may differ between optimistic (user's name)
81
+ // and real message (server may use 'Dashboard' as relay client name)
82
+ const recentMessages = messages.slice(-50); // Only check recent messages for performance
83
+ const realFingerprints = new Set(
84
+ recentMessages.map((m) => `${m.to}:${m.content.slice(0, 100)}`)
85
+ );
86
+
87
+ // Remove optimistic messages that now exist in real messages
88
+ setOptimisticMessages((prev) =>
89
+ prev.filter((opt) => {
90
+ const fingerprint = `${opt.to}:${opt.content.slice(0, 100)}`;
91
+ return !realFingerprints.has(fingerprint);
92
+ })
93
+ );
94
+ }, [messages, optimisticMessages.length]);
95
+
96
+ // Combine real messages with optimistic messages, sorted by timestamp
97
+ const allMessages = useMemo(() => {
98
+ if (optimisticMessages.length === 0) return messages;
99
+ return [...messages, ...optimisticMessages].sort(
100
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
101
+ );
102
+ }, [messages, optimisticMessages]);
103
+
104
+ // Filter messages by current channel
105
+ // Only exclude reply-chain replies (where thread is another message's ID)
106
+ // Keep topic thread messages (where thread is a topic name, not a message ID)
107
+ const filteredMessages = useMemo(() => {
108
+ // Build set of message IDs for efficient lookup
109
+ const messageIds = new Set(allMessages.map((m) => m.id));
110
+
111
+ // Filter out reply-chain replies (thread points to existing message ID)
112
+ // Keep topic thread messages (thread is a name, not a message ID)
113
+ const mainViewMessages = allMessages.filter((m) => {
114
+ if (!m.thread) return true; // No thread - show it
115
+ // If thread is a message ID, it's a reply - hide it from main view
116
+ // If thread is a topic name, show it
117
+ return !messageIds.has(m.thread);
118
+ });
119
+
120
+ if (currentChannel === 'general') {
121
+ return mainViewMessages;
122
+ }
123
+ return mainViewMessages.filter(
124
+ (m) => m.from === currentChannel || m.to === currentChannel
125
+ );
126
+ }, [allMessages, currentChannel]);
127
+
128
+ // Get messages for a specific thread
129
+ const threadMessages = useCallback(
130
+ (threadId: string) => allMessages.filter((m) => m.thread === threadId),
131
+ [allMessages]
132
+ );
133
+
134
+ // Calculate active threads with unread counts
135
+ const activeThreads = useMemo((): ThreadInfo[] => {
136
+ const threadMap = new Map<string, Message[]>();
137
+ const messageIds = new Set(allMessages.map((m) => m.id));
138
+
139
+ // Group messages by thread
140
+ for (const msg of allMessages) {
141
+ if (msg.thread) {
142
+ const existing = threadMap.get(msg.thread) || [];
143
+ existing.push(msg);
144
+ threadMap.set(msg.thread, existing);
145
+ }
146
+ }
147
+
148
+ // Convert to ThreadInfo array
149
+ const threads: ThreadInfo[] = [];
150
+ for (const [threadId, threadMsgs] of threadMap.entries()) {
151
+ // Sort by timestamp to get the last message
152
+ const sorted = [...threadMsgs].sort(
153
+ (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
154
+ );
155
+
156
+ // Get unique participants
157
+ const participants = [...new Set(threadMsgs.flatMap((m) => [m.from, m.to]))].filter(
158
+ (p) => p !== '*'
159
+ );
160
+
161
+ // Count unread messages in thread
162
+ // Consider messages as "read" if they arrived before we last viewed this thread
163
+ // Exclude messages from current user - users shouldn't get notifications for their own messages
164
+ const seenTimestamp = seenThreads.get(threadId);
165
+ const unreadCount = threadMsgs.filter((m) => {
166
+ if (m.from === effectiveSenderName) return false; // Don't count own messages as unread
167
+ if (m.isRead) return false; // Already marked as read
168
+ if (seenTimestamp) {
169
+ // If we've seen this thread, only count messages after that time
170
+ return new Date(m.timestamp).getTime() > seenTimestamp;
171
+ }
172
+ return true; // Not seen yet, count as unread
173
+ }).length;
174
+
175
+ // Determine thread name: if threadId is a message ID, use first message content as name
176
+ let name = threadId;
177
+ if (messageIds.has(threadId)) {
178
+ // Find the original message that started the thread
179
+ const originalMsg = allMessages.find((m) => m.id === threadId);
180
+ if (originalMsg) {
181
+ // Use first line of content, truncated
182
+ const firstLine = originalMsg.content.split('\n')[0];
183
+ name = firstLine.length > 30 ? firstLine.substring(0, 30) + '...' : firstLine;
184
+ }
185
+ }
186
+
187
+ threads.push({
188
+ id: threadId,
189
+ name,
190
+ lastMessage: sorted[0],
191
+ messageCount: threadMsgs.length,
192
+ unreadCount,
193
+ participants,
194
+ });
195
+ }
196
+
197
+ // Sort by last activity (most recent first)
198
+ return threads.sort(
199
+ (a, b) =>
200
+ new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
201
+ );
202
+ }, [allMessages, seenThreads]);
203
+
204
+ // Wrapper for setCurrentThread that also marks the thread as seen
205
+ const setCurrentThread = useCallback((threadId: string | null) => {
206
+ setCurrentThreadInternal(threadId);
207
+ if (threadId) {
208
+ // Mark thread as seen with current timestamp
209
+ setSeenThreads((prev) => {
210
+ const next = new Map(prev);
211
+ next.set(threadId, Date.now());
212
+ return next;
213
+ });
214
+ }
215
+ }, []);
216
+
217
+ // Calculate total unread threads
218
+ const totalUnreadThreadCount = useMemo(
219
+ () => activeThreads.filter((t) => t.unreadCount > 0).length,
220
+ [activeThreads]
221
+ );
222
+
223
+ // Calculate stats
224
+ const stats = useMemo(() => {
225
+ const unread = allMessages.filter((m) => !m.isRead).length;
226
+ return {
227
+ totalCount: allMessages.length,
228
+ unreadCount: unread,
229
+ };
230
+ }, [allMessages]);
231
+
232
+ // Send message function with optimistic updates
233
+ const sendMessage = useCallback(
234
+ async (to: string, content: string, thread?: string, attachmentIds?: string[]): Promise<boolean> => {
235
+ setIsSending(true);
236
+ setSendError(null);
237
+
238
+ // Create optimistic message and add it immediately for snappy UX
239
+ const from = effectiveSenderName;
240
+ const optimisticId = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
241
+ const optimisticMsg: Message = {
242
+ id: optimisticId,
243
+ from,
244
+ to,
245
+ content,
246
+ timestamp: new Date().toISOString(),
247
+ status: 'sending',
248
+ thread,
249
+ isRead: true, // User's own messages are always "read"
250
+ };
251
+
252
+ // Add optimistic message immediately - UI updates instantly
253
+ setOptimisticMessages((prev) => [...prev, optimisticMsg]);
254
+
255
+ try {
256
+ const request: SendMessageRequest & { from?: string } = {
257
+ to,
258
+ message: content,
259
+ thread,
260
+ attachments: attachmentIds,
261
+ };
262
+
263
+ // Include sender name for cloud mode (GitHub username)
264
+ if (senderName) {
265
+ request.from = senderName;
266
+ }
267
+
268
+ // Use api.sendMessage which handles:
269
+ // - Workspace proxy routing (in cloud mode)
270
+ // - CSRF token headers
271
+ // - Credentials
272
+ const result = await api.sendMessage(request);
273
+
274
+ if (result.success) {
275
+ // Success! The optimistic message will be cleaned up when
276
+ // the real message arrives via WebSocket
277
+ return true;
278
+ }
279
+
280
+ // Failed - remove the optimistic message and show error
281
+ setOptimisticMessages((prev) => prev.filter((m) => m.id !== optimisticId));
282
+ setSendError(result.error || 'Failed to send message');
283
+ return false;
284
+ } catch (_error) {
285
+ // Network error - remove optimistic message
286
+ setOptimisticMessages((prev) => prev.filter((m) => m.id !== optimisticId));
287
+ setSendError('Network error');
288
+ return false;
289
+ } finally {
290
+ setIsSending(false);
291
+ }
292
+ },
293
+ [effectiveSenderName, senderName]
294
+ );
295
+
296
+ return {
297
+ messages: filteredMessages,
298
+ threadMessages,
299
+ currentChannel,
300
+ setCurrentChannel,
301
+ currentThread: currentThreadInternal,
302
+ setCurrentThread,
303
+ activeThreads,
304
+ totalUnreadThreadCount,
305
+ sendMessage,
306
+ isSending,
307
+ sendError,
308
+ ...stats,
309
+ };
310
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Tests for useOrchestrator hook
3
+ *
4
+ * Covers: spawnAgent cwd parameter forwarding.
5
+ */
6
+
7
+ // @vitest-environment jsdom
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+ import { renderHook, act } from '@testing-library/react';
10
+ import { useOrchestrator } from './useOrchestrator';
11
+
12
+ // Mock WebSocket to prevent actual connections
13
+ vi.stubGlobal(
14
+ 'WebSocket',
15
+ class MockWebSocket {
16
+ static CLOSED = 3;
17
+ readyState = 3;
18
+ onopen: (() => void) | null = null;
19
+ onclose: (() => void) | null = null;
20
+ onmessage: ((e: unknown) => void) | null = null;
21
+ onerror: ((e: unknown) => void) | null = null;
22
+ close() {
23
+ /* no-op */
24
+ }
25
+ },
26
+ );
27
+
28
+ // Mock fetch
29
+ const mockFetch = vi.fn();
30
+
31
+ describe('useOrchestrator', () => {
32
+ beforeEach(() => {
33
+ vi.stubGlobal('fetch', mockFetch);
34
+ mockFetch.mockReset();
35
+ // Default mock for workspace/agents fetches
36
+ mockFetch.mockImplementation(async (url: string) => {
37
+ if (url.includes('/workspaces') && !url.includes('/agents')) {
38
+ return {
39
+ ok: true,
40
+ json: async () => ({
41
+ workspaces: [
42
+ { id: 'ws-1', name: 'test', lastActiveAt: new Date().toISOString() },
43
+ ],
44
+ activeWorkspaceId: 'ws-1',
45
+ }),
46
+ };
47
+ }
48
+ if (url.includes('/agents') && !url.includes('POST')) {
49
+ return {
50
+ ok: true,
51
+ json: async () => ({ agents: [] }),
52
+ };
53
+ }
54
+ return { ok: true, json: async () => ({}) };
55
+ });
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.unstubAllGlobals();
60
+ // Re-stub WebSocket for next test
61
+ vi.stubGlobal(
62
+ 'WebSocket',
63
+ class MockWebSocket {
64
+ static CLOSED = 3;
65
+ readyState = 3;
66
+ onopen: (() => void) | null = null;
67
+ onclose: (() => void) | null = null;
68
+ onmessage: ((e: unknown) => void) | null = null;
69
+ onerror: ((e: unknown) => void) | null = null;
70
+ close() {
71
+ /* no-op */
72
+ }
73
+ },
74
+ );
75
+ });
76
+
77
+ describe('spawnAgent', () => {
78
+ it('includes cwd in POST body when provided', async () => {
79
+ const { result } = renderHook(() =>
80
+ useOrchestrator({ apiUrl: 'http://localhost:3456', enabled: true }),
81
+ );
82
+
83
+ // Wait for initial data fetch
84
+ await vi.waitFor(() => {
85
+ expect(result.current.isLoading).toBe(false);
86
+ });
87
+
88
+ // Set up mock for the spawn POST
89
+ mockFetch.mockImplementationOnce(async (_url: string, opts?: RequestInit) => {
90
+ return {
91
+ ok: true,
92
+ json: async () => ({
93
+ id: 'agent-1',
94
+ name: 'worker',
95
+ workspaceId: 'ws-1',
96
+ provider: 'claude',
97
+ status: 'running',
98
+ spawnedAt: new Date().toISOString(),
99
+ restartCount: 0,
100
+ }),
101
+ };
102
+ });
103
+
104
+ // Call spawnAgent with cwd
105
+ await act(async () => {
106
+ await result.current.spawnAgent('worker', 'do stuff', 'claude', 'trajectories');
107
+ });
108
+
109
+ // Find the POST call to /agents (spawn call)
110
+ const spawnCall = mockFetch.mock.calls.find(
111
+ (call) =>
112
+ typeof call[0] === 'string' &&
113
+ call[0].includes('/agents') &&
114
+ call[1]?.method === 'POST',
115
+ );
116
+ expect(spawnCall).toBeTruthy();
117
+
118
+ const body = JSON.parse(spawnCall![1].body as string);
119
+ expect(body.cwd).toBe('trajectories');
120
+ expect(body.name).toBe('worker');
121
+ expect(body.task).toBe('do stuff');
122
+ expect(body.provider).toBe('claude');
123
+ });
124
+
125
+ it('sends cwd as undefined when not provided', async () => {
126
+ const { result } = renderHook(() =>
127
+ useOrchestrator({ apiUrl: 'http://localhost:3456', enabled: true }),
128
+ );
129
+
130
+ await vi.waitFor(() => {
131
+ expect(result.current.isLoading).toBe(false);
132
+ });
133
+
134
+ mockFetch.mockImplementationOnce(async () => ({
135
+ ok: true,
136
+ json: async () => ({
137
+ id: 'agent-1',
138
+ name: 'worker',
139
+ workspaceId: 'ws-1',
140
+ provider: 'claude',
141
+ status: 'running',
142
+ spawnedAt: new Date().toISOString(),
143
+ restartCount: 0,
144
+ }),
145
+ }));
146
+
147
+ await act(async () => {
148
+ await result.current.spawnAgent('worker', 'do stuff', 'claude');
149
+ });
150
+
151
+ const spawnCall = mockFetch.mock.calls.find(
152
+ (call) =>
153
+ typeof call[0] === 'string' &&
154
+ call[0].includes('/agents') &&
155
+ call[1]?.method === 'POST',
156
+ );
157
+ expect(spawnCall).toBeTruthy();
158
+
159
+ const body = JSON.parse(spawnCall![1].body as string);
160
+ expect(body.name).toBe('worker');
161
+ // cwd should not be set (undefined serializes away in JSON.stringify)
162
+ expect(body.cwd).toBeUndefined();
163
+ });
164
+ });
165
+ });