@agent-relay/dashboard 2.0.82 → 2.0.84

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 (228) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/1028-da5d75e35d1420f1.js +1 -0
  3. package/out/_next/static/chunks/1528-78b17000a7e10bc6.js +2 -0
  4. package/out/_next/static/chunks/1695-4a5d33ba715e09b4.js +1 -0
  5. package/out/_next/static/chunks/1705-36c2180d00a4a569.js +1 -0
  6. package/out/_next/static/chunks/1dd3208c-e1f87c7b3dc1a820.js +1 -0
  7. package/out/_next/static/chunks/3663-47290254b8f6f5dd.js +1 -0
  8. package/out/_next/static/chunks/3677-4b225baf4801d9b9.js +73 -0
  9. package/out/_next/static/chunks/5118-7e8ada2df38eef07.js +1 -0
  10. package/out/_next/static/chunks/5888-15cbe97c90ed5fae.js +1 -0
  11. package/out/_next/static/chunks/6773-a45343a98df3abb5.js +1 -0
  12. package/out/_next/static/chunks/6940-b824612b605e79b3.js +9 -0
  13. package/out/_next/static/chunks/7894-f4a15249082a680d.js +1 -0
  14. package/out/_next/static/chunks/9175-b3617c1e5cbfed0e.js +1 -0
  15. package/out/_next/static/chunks/9372-1a804b8d08c7a236.js +1 -0
  16. package/out/_next/static/chunks/{ab6c8a12-0a58072fbb505134.js → ab6c8a12-91438a812d94ecf0.js} +1 -1
  17. package/out/_next/static/chunks/app/_not-found/page-8e8842f82d204726.js +1 -0
  18. package/out/_next/static/chunks/app/about/page-b78577a7da8fa459.js +1 -0
  19. package/out/_next/static/chunks/app/app/[[...slug]]/page-3dffd65b6344f53e.js +1 -0
  20. package/out/_next/static/chunks/app/app/onboarding/page-b89be9aa6264a5e1.js +1 -0
  21. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-fbd00893ef69e499.js +1 -0
  22. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-de2ea13649d0b6d3.js +1 -0
  23. package/out/_next/static/chunks/app/blog/page-a08e263c57a156fa.js +1 -0
  24. package/out/_next/static/chunks/app/careers/page-02228e1d6969b232.js +1 -0
  25. package/out/_next/static/chunks/app/changelog/page-1b5c1d79efc6e53a.js +1 -0
  26. package/out/_next/static/chunks/app/cloud/link/page-99654edffffb3af2.js +1 -0
  27. package/out/_next/static/chunks/app/complete-profile/page-59d146e5ddeafc5c.js +1 -0
  28. package/out/_next/static/chunks/app/connect-repos/page-995e16a976a6632c.js +1 -0
  29. package/out/_next/static/chunks/app/contact/page-273396a5ad57bcee.js +1 -0
  30. package/out/_next/static/chunks/app/dev/cli-tools/page-a71b80dcb2d5fc8d.js +1 -0
  31. package/out/_next/static/chunks/app/dev/log-viewer/page-46a6151ae1be0796.js +1 -0
  32. package/out/_next/static/chunks/app/docs/page-7c7cb603b24b7c40.js +1 -0
  33. package/out/_next/static/chunks/app/history/page-0c5cab1dab4e8886.js +1 -0
  34. package/out/_next/static/chunks/app/layout-96d72ba8ef8a43a0.js +1 -0
  35. package/out/_next/static/chunks/app/login/page-0ccbab34213df842.js +1 -0
  36. package/out/_next/static/chunks/app/metrics/page-8616272aeab9c8b0.js +1 -0
  37. package/out/_next/static/chunks/app/page-09ce10603ad9a251.js +1 -0
  38. package/out/_next/static/chunks/app/pricing/page-91c975079120c941.js +1 -0
  39. package/out/_next/static/chunks/app/privacy/{page-c21d51ac2dee3a88.js → page-a49ab271cc686644.js} +1 -1
  40. package/out/_next/static/chunks/app/providers/{page-59114505f4353512.js → page-d775d6eb5bc29e96.js} +1 -1
  41. package/out/_next/static/chunks/app/providers/setup/[provider]/page-ec4ef3cd80de807e.js +1 -0
  42. package/out/_next/static/chunks/app/security/page-d9da9bd9191e8f95.js +1 -0
  43. package/out/_next/static/chunks/app/signup/page-930eca0bf5fd299d.js +1 -0
  44. package/out/_next/static/chunks/app/terms/page-3e4827620b98613c.js +1 -0
  45. package/out/_next/static/chunks/framework-648e1ae7da590300.js +1 -0
  46. package/out/_next/static/chunks/{main-acb1b24265295d6a.js → main-2b1990080c292d92.js} +1 -1
  47. package/out/_next/static/chunks/main-app-9f6b7ff9e754a8f5.js +1 -0
  48. package/out/_next/static/chunks/pages/_app-a077b72e02273ab1.js +1 -0
  49. package/out/_next/static/chunks/pages/_error-84001666436a04e4.js +1 -0
  50. package/out/_next/static/chunks/{webpack-dd93b81e2659669c.js → webpack-7586035f1585f2db.js} +1 -1
  51. package/out/_next/static/css/eb9fc69d1e3d2bed.css +1 -0
  52. package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_buildManifest.js +1 -1
  53. package/out/about.html +2 -2
  54. package/out/about.txt +2 -2
  55. package/out/app/onboarding.html +1 -1
  56. package/out/app/onboarding.txt +2 -2
  57. package/out/app.html +1 -1
  58. package/out/app.txt +2 -2
  59. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +3 -3
  60. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  61. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  62. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  63. package/out/blog.html +2 -2
  64. package/out/blog.txt +1 -1
  65. package/out/careers.html +2 -2
  66. package/out/careers.txt +2 -2
  67. package/out/changelog.html +2 -2
  68. package/out/changelog.txt +2 -2
  69. package/out/cloud/link.html +1 -1
  70. package/out/cloud/link.txt +2 -2
  71. package/out/complete-profile.html +2 -2
  72. package/out/complete-profile.txt +2 -2
  73. package/out/connect-repos.html +1 -1
  74. package/out/connect-repos.txt +2 -2
  75. package/out/contact.html +2 -2
  76. package/out/contact.txt +2 -2
  77. package/out/dev/cli-tools.html +1 -0
  78. package/out/dev/cli-tools.txt +7 -0
  79. package/out/dev/log-viewer.html +23 -0
  80. package/out/dev/log-viewer.txt +7 -0
  81. package/out/docs.html +2 -2
  82. package/out/docs.txt +2 -2
  83. package/out/history.html +1 -1
  84. package/out/history.txt +2 -2
  85. package/out/index.html +1 -1
  86. package/out/index.txt +2 -2
  87. package/out/login.html +2 -2
  88. package/out/login.txt +2 -2
  89. package/out/metrics.html +1 -1
  90. package/out/metrics.txt +2 -2
  91. package/out/pricing.html +2 -2
  92. package/out/pricing.txt +2 -2
  93. package/out/privacy.html +2 -2
  94. package/out/privacy.txt +2 -2
  95. package/out/providers/setup/claude.html +1 -1
  96. package/out/providers/setup/claude.txt +2 -2
  97. package/out/providers/setup/codex.html +1 -1
  98. package/out/providers/setup/codex.txt +2 -2
  99. package/out/providers/setup/cursor.html +1 -1
  100. package/out/providers/setup/cursor.txt +2 -2
  101. package/out/providers.html +1 -1
  102. package/out/providers.txt +2 -2
  103. package/out/security.html +2 -2
  104. package/out/security.txt +2 -2
  105. package/out/signup.html +2 -2
  106. package/out/signup.txt +2 -2
  107. package/out/terms.html +2 -2
  108. package/out/terms.txt +2 -2
  109. package/package.json +5 -1
  110. package/src/adapters/DashboardConfigProvider.tsx +56 -0
  111. package/src/adapters/cloudFetchAdapter.ts +278 -0
  112. package/src/adapters/index.ts +3 -0
  113. package/src/adapters/types.ts +508 -0
  114. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +67 -18
  115. package/src/app/app/onboarding/page.tsx +870 -170
  116. package/src/app/cloud/link/page.tsx +14 -6
  117. package/src/app/connect-repos/page.tsx +9 -3
  118. package/src/app/dev/cli-tools/page.tsx +130 -0
  119. package/src/app/dev/log-viewer/MockLogViewer.tsx +132 -0
  120. package/src/app/dev/log-viewer/fixtures.ts +110 -0
  121. package/src/app/dev/log-viewer/page.tsx +288 -0
  122. package/src/app/history/page.tsx +28 -12
  123. package/src/app/page.tsx +1 -1
  124. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +209 -59
  125. package/src/components/AgentCard.tsx +4 -4
  126. package/src/components/AgentLogPreview.tsx +2 -38
  127. package/src/components/App.tsx +441 -2624
  128. package/src/components/CliToolHarness.test.tsx +83 -0
  129. package/src/components/CliToolHarness.tsx +292 -0
  130. package/src/components/CoordinatorPanel.tsx +13 -6
  131. package/src/components/LogViewer.tsx +2 -42
  132. package/src/components/ProviderAuthFlow.tsx +201 -81
  133. package/src/components/ProvisioningProgress.tsx +1 -1
  134. package/src/components/ReactionChips.tsx +2 -1
  135. package/src/components/SpawnModal.test.tsx +51 -18
  136. package/src/components/SpawnModal.tsx +175 -207
  137. package/src/components/TerminalProviderSetup.tsx +1 -1
  138. package/src/components/ThreadPanel.tsx +2 -0
  139. package/src/components/WorkspaceContext.tsx +7 -19
  140. package/src/components/XTermLogViewer.tsx +190 -27
  141. package/src/components/channels/ChannelMessageList.tsx +94 -4
  142. package/src/components/channels/ChannelViewV1.tsx +35 -11
  143. package/src/components/channels/api.ts +21 -20
  144. package/src/components/channels/types.ts +16 -0
  145. package/src/components/hooks/index.ts +0 -19
  146. package/src/components/hooks/useMessages.test.ts +80 -0
  147. package/src/components/hooks/useMessages.ts +13 -4
  148. package/src/components/hooks/useOrchestrator.ts +1 -1
  149. package/src/components/hooks/usePresence.ts +45 -6
  150. package/src/components/hooks/useThread.ts +83 -46
  151. package/src/components/hooks/useTrajectory.ts +62 -5
  152. package/src/components/hooks/useWebSocket.test.ts +358 -0
  153. package/src/components/hooks/useWebSocket.ts +243 -5
  154. package/src/components/index.ts +2 -14
  155. package/src/components/layout/Header.tsx +9 -15
  156. package/src/components/layout/Sidebar.tsx +1 -8
  157. package/src/components/settings/SettingsPage.tsx +108 -47
  158. package/src/components/settings/index.ts +0 -3
  159. package/src/landing/blogData.ts +1 -1
  160. package/src/lib/agent-merge.test.ts +2 -2
  161. package/src/lib/api.ts +8 -38
  162. package/src/lib/identity.test.ts +139 -0
  163. package/src/lib/identity.ts +48 -0
  164. package/src/lib/relaycastMessageAdapters.test.ts +182 -0
  165. package/src/lib/relaycastMessageAdapters.ts +105 -0
  166. package/src/lib/sanitize-logs.test.ts +227 -0
  167. package/src/lib/sanitize-logs.ts +202 -0
  168. package/src/providers/AgentProvider.tsx +799 -0
  169. package/src/providers/ChannelProvider.tsx +528 -0
  170. package/src/providers/CloudWorkspaceProvider.tsx +402 -0
  171. package/src/providers/MessageProvider.tsx +875 -0
  172. package/src/providers/RelayConfigProvider.tsx +94 -0
  173. package/src/providers/SendProvider.tsx +497 -0
  174. package/src/providers/SettingsProvider.tsx +247 -0
  175. package/src/providers/index.ts +26 -0
  176. package/src/types/index.ts +10 -10
  177. package/out/_next/static/chunks/11-9a2993a37266dcb3.js +0 -9
  178. package/out/_next/static/chunks/118-ae2b650136a5a5fc.js +0 -1
  179. package/out/_next/static/chunks/1dd3208c-40ab0fc0f60392b8.js +0 -1
  180. package/out/_next/static/chunks/202-fc0763dd7488e58f.js +0 -1
  181. package/out/_next/static/chunks/259-83b77fa1b91ba5aa.js +0 -1
  182. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +0 -1
  183. package/out/_next/static/chunks/528-f5f676996d613c25.js +0 -2
  184. package/out/_next/static/chunks/663-ddb04081febc3678.js +0 -1
  185. package/out/_next/static/chunks/687-88b6b139a6bb0e2e.js +0 -1
  186. package/out/_next/static/chunks/695-51d25b1988644374.js +0 -1
  187. package/out/_next/static/chunks/773-54a2641043c81e55.js +0 -1
  188. package/out/_next/static/chunks/app/_not-found/page-6da9b72091e5b511.js +0 -1
  189. package/out/_next/static/chunks/app/about/page-fff7c6457683f243.js +0 -1
  190. package/out/_next/static/chunks/app/app/[[...slug]]/page-f7eca1b66fb4249b.js +0 -1
  191. package/out/_next/static/chunks/app/app/onboarding/page-129abc5da2e67971.js +0 -1
  192. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-5d5f28fd126b692f.js +0 -1
  193. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-b194f207fbd91862.js +0 -1
  194. package/out/_next/static/chunks/app/blog/page-b9bd9d8703fca76a.js +0 -1
  195. package/out/_next/static/chunks/app/careers/page-a4bd8d5f4de8f4eb.js +0 -1
  196. package/out/_next/static/chunks/app/changelog/page-9a1f6ad1743d63c5.js +0 -1
  197. package/out/_next/static/chunks/app/cloud/link/page-0844c5699b027c3b.js +0 -1
  198. package/out/_next/static/chunks/app/complete-profile/page-39ed5a67916beb87.js +0 -1
  199. package/out/_next/static/chunks/app/connect-repos/page-297eddee0c39f2a3.js +0 -1
  200. package/out/_next/static/chunks/app/contact/page-3c1dd8690217fade.js +0 -1
  201. package/out/_next/static/chunks/app/docs/page-1875e981f2c3fd13.js +0 -1
  202. package/out/_next/static/chunks/app/history/page-2d5c5695c9e8b40c.js +0 -1
  203. package/out/_next/static/chunks/app/layout-0a4b99656da25511.js +0 -1
  204. package/out/_next/static/chunks/app/login/page-f69c076f5a6fc520.js +0 -1
  205. package/out/_next/static/chunks/app/metrics/page-bebbee055669a17e.js +0 -1
  206. package/out/_next/static/chunks/app/page-0ee604f7070d14c0.js +0 -1
  207. package/out/_next/static/chunks/app/pricing/page-eeae7d594af333b6.js +0 -1
  208. package/out/_next/static/chunks/app/providers/setup/[provider]/page-daf9b3e05e77ae19.js +0 -1
  209. package/out/_next/static/chunks/app/security/page-cd562730fe84a0a2.js +0 -1
  210. package/out/_next/static/chunks/app/signup/page-c242ca08101a84ff.js +0 -1
  211. package/out/_next/static/chunks/app/terms/page-c7001720e7941dc6.js +0 -1
  212. package/out/_next/static/chunks/framework-3664cab31236a9fa.js +0 -1
  213. package/out/_next/static/chunks/main-app-7f73a939a312a228.js +0 -1
  214. package/out/_next/static/chunks/pages/_app-10a93ab5b7c32eb3.js +0 -1
  215. package/out/_next/static/chunks/pages/_error-2d792b2a41857be4.js +0 -1
  216. package/out/_next/static/css/8968d98ed4c4d33f.css +0 -1
  217. package/src/components/BillingResult.tsx +0 -447
  218. package/src/components/CloudSessionProvider.tsx +0 -130
  219. package/src/components/SessionExpiredModal.tsx +0 -128
  220. package/src/components/WorkspaceStatusIndicator.tsx +0 -396
  221. package/src/components/hooks/useSession.ts +0 -209
  222. package/src/components/hooks/useWorkspaceMembers.ts +0 -132
  223. package/src/components/hooks/useWorkspaceStatus.ts +0 -237
  224. package/src/components/settings/BillingSettingsPanel.tsx +0 -564
  225. package/src/components/settings/TeamSettingsPanel.tsx +0 -560
  226. package/src/components/settings/WorkspaceSettingsPanel.tsx +0 -1368
  227. package/src/lib/cloudApi.ts +0 -893
  228. /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_ssgManifest.js +0 -0
@@ -0,0 +1,875 @@
1
+ /**
2
+ * Message Provider
3
+ *
4
+ * Manages core message state, threads, DM conversations, presence,
5
+ * notifications, and WebSocket event handling. Composes ChannelProvider
6
+ * and SendProvider as children for channel CRUD and send operations.
7
+ *
8
+ * All values from the sub-providers are re-exported through useMessageContext
9
+ * for backward compatibility.
10
+ */
11
+
12
+ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
13
+ import type { Message } from '../types';
14
+ import type { HumanUser } from '../components/MentionAutocomplete';
15
+ import {
16
+ useDMs as useRelayDMs,
17
+ } from '@relaycast/react';
18
+ import { useMessages as useMessagesHook } from '../components/hooks/useMessages';
19
+ import { useThread } from '../components/hooks/useThread';
20
+ import { usePresence, type UserPresence } from '../components/hooks/usePresence';
21
+ import { useDirectMessage } from '../components/hooks/useDirectMessage';
22
+ import { useCloudWorkspace } from './CloudWorkspaceProvider';
23
+ import { useAgentContext } from './AgentProvider';
24
+ import { useRelayConfigStatus } from './RelayConfigProvider';
25
+ import { isDashboardVariant } from '../lib/identity';
26
+ import {
27
+ normalizeRelayDmMessageTargets,
28
+ } from '../lib/relaycastMessageAdapters';
29
+ import { playNotificationSound } from './SettingsProvider';
30
+ import { useSettings } from './SettingsProvider';
31
+ import {
32
+ type Channel,
33
+ type ChannelMember,
34
+ type ChannelMessage as ChannelApiMessage,
35
+ type UnreadState,
36
+ type CreateChannelRequest,
37
+ } from '../components/channels';
38
+ import type { DashboardData } from '../components/hooks/useWebSocket';
39
+ import { ChannelProvider, useChannelContext } from './ChannelProvider';
40
+ import { SendProvider, useSendContext } from './SendProvider';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Constants
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Special ID for the Activity feed (broadcasts) */
47
+ export const ACTIVITY_FEED_ID = '__activity__';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helper
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function isHumanSender(sender: string, agentNames: Set<string>, projectIdentity?: string | null): boolean {
54
+ return !isDashboardVariant(sender) &&
55
+ (projectIdentity ? sender !== projectIdentity : true) &&
56
+ sender !== '*' &&
57
+ !agentNames.has(sender.toLowerCase());
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Types
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface MessageContextValue {
65
+ // Core message state
66
+ messages: Message[];
67
+ threadMessages: (threadId: string) => Message[];
68
+ currentChannel: string;
69
+ setCurrentChannel: (ch: string) => void;
70
+ currentThread: string | null;
71
+ setCurrentThread: (t: string | null) => void;
72
+ activeThreads: ReturnType<typeof useMessagesHook>['activeThreads'];
73
+ totalUnreadThreadCount: number;
74
+ sendMessage: (to: string, content: string, thread?: string, attachmentIds?: string[]) => Promise<boolean>;
75
+ isSending: boolean;
76
+ sendError: string | null;
77
+
78
+ // Thread hook (API-backed)
79
+ thread: ReturnType<typeof useThread>;
80
+
81
+ // Channel state (from ChannelProvider)
82
+ viewMode: 'local' | 'fleet' | 'channels';
83
+ setViewMode: React.Dispatch<React.SetStateAction<'local' | 'fleet' | 'channels'>>;
84
+ channelsList: Channel[];
85
+ archivedChannelsList: Channel[];
86
+ channelMessages: ChannelApiMessage[];
87
+ selectedChannelId: string | undefined;
88
+ setSelectedChannelId: React.Dispatch<React.SetStateAction<string | undefined>>;
89
+ selectedChannel: Channel | undefined;
90
+ hasMoreMessages: boolean;
91
+ channelUnreadState: UnreadState | undefined;
92
+ isChannelsLoading: boolean;
93
+ effectiveChannelMessages: ChannelApiMessage[];
94
+
95
+ // Channel handlers (from ChannelProvider)
96
+ handleSelectChannel: (channel: Channel) => Promise<void>;
97
+ handleCreateChannel: () => void;
98
+ handleCreateChannelSubmit: (request: CreateChannelRequest) => Promise<void>;
99
+ handleInviteToChannel: (channel: Channel) => void;
100
+ handleInviteSubmit: (members: string[]) => Promise<void>;
101
+ handleJoinChannel: (channelId: string) => Promise<void>;
102
+ handleLeaveChannel: (channel: Channel) => Promise<void>;
103
+ handleShowMembers: () => Promise<void>;
104
+ handleRemoveMember: (memberId: string, memberType: 'user' | 'agent') => Promise<void>;
105
+ handleAddMember: (memberId: string, memberType: 'user' | 'agent', role: 'admin' | 'member' | 'read_only') => Promise<void>;
106
+ handleArchiveChannel: (channel: Channel) => Promise<void>;
107
+ handleUnarchiveChannel: (channel: Channel) => Promise<void>;
108
+ handleSendChannelMessage: (content: string, threadId?: string, attachmentIds?: string[]) => Promise<boolean>;
109
+ handleLoadMoreMessages: () => Promise<void>;
110
+ handleMarkChannelRead: (channelId: string) => void;
111
+
112
+ // Channel modals (from ChannelProvider)
113
+ isCreateChannelOpen: boolean;
114
+ setIsCreateChannelOpen: React.Dispatch<React.SetStateAction<boolean>>;
115
+ isCreatingChannel: boolean;
116
+ isInviteChannelOpen: boolean;
117
+ setIsInviteChannelOpen: React.Dispatch<React.SetStateAction<boolean>>;
118
+ inviteChannelTarget: Channel | null;
119
+ setInviteChannelTarget: React.Dispatch<React.SetStateAction<Channel | null>>;
120
+ isInvitingToChannel: boolean;
121
+ showMemberPanel: boolean;
122
+ setShowMemberPanel: React.Dispatch<React.SetStateAction<boolean>>;
123
+ channelMembers: ChannelMember[];
124
+
125
+ // DM state
126
+ currentHuman: import('../types').Agent | null;
127
+ selectedDmAgents: string[];
128
+ removedDmAgents: string[];
129
+ dedupedVisibleMessages: Message[];
130
+ dmParticipantAgents: string[];
131
+ dmSelectedAgentsByHuman: Record<string, string[]>;
132
+ handleDmAgentToggle: (agentName: string) => void;
133
+ handleDmSend: (content: string, attachmentIds?: string[]) => Promise<boolean>;
134
+ handleMainComposerSend: (content: string, attachmentIds?: string[]) => Promise<boolean>;
135
+
136
+ // Presence
137
+ onlineUsers: UserPresence[];
138
+ typingUsers: ReturnType<typeof usePresence>['typingUsers'];
139
+ sendTyping: ReturnType<typeof usePresence>['sendTyping'];
140
+ isPresenceConnected: boolean;
141
+
142
+ // Human users
143
+ humanUsers: HumanUser[];
144
+ humanUnreadCounts: Record<string, number>;
145
+
146
+ // Reactions (from SendProvider)
147
+ handleReaction: (messageId: string, emoji: string, hasReacted: boolean) => Promise<void>;
148
+
149
+ // DM tracking
150
+ markDmSeen: (username: string) => void;
151
+
152
+ // User profile
153
+ selectedUserProfile: UserPresence | null;
154
+ setSelectedUserProfile: React.Dispatch<React.SetStateAction<UserPresence | null>>;
155
+ pendingMention: string | undefined;
156
+ setPendingMention: React.Dispatch<React.SetStateAction<string | undefined>>;
157
+
158
+ // Notification state
159
+ hasUnreadMessages: boolean;
160
+
161
+ // WebSocket event handler ref (for passing to parent hooks)
162
+ handlePresenceEvent: (event: unknown) => void;
163
+
164
+ // Channel message map setter for external channel updates
165
+ setChannelsList: React.Dispatch<React.SetStateAction<Channel[]>>;
166
+ appendChannelMessage: (channelId: string, message: ChannelApiMessage, options?: { incrementUnread?: boolean }) => void;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Context
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const MessageContext = createContext<MessageContextValue | null>(null);
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Provider Props
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export interface MessageProviderProps {
180
+ children: React.ReactNode;
181
+ data: DashboardData | null;
182
+ rawData: DashboardData | null;
183
+ enableReactions?: boolean;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Inner component that reads from ChannelProvider and SendProvider
188
+ // ---------------------------------------------------------------------------
189
+
190
+ interface MessageProviderInnerProps {
191
+ children: React.ReactNode;
192
+ data: DashboardData | null;
193
+ rawData: DashboardData | null;
194
+ enableReactions?: boolean;
195
+ }
196
+
197
+ function MessageProviderInner({ children, data, rawData: _rawData, enableReactions = false }: MessageProviderInnerProps) {
198
+ const { currentUser, effectiveActiveWorkspaceId, isWorkspaceFeaturesEnabled } = useCloudWorkspace();
199
+ const { agents, combinedAgents, addActivityEvent } = useAgentContext();
200
+ const { configured: relayConfigured, agentName: relayAgentName } = useRelayConfigStatus();
201
+ const { settings } = useSettings();
202
+
203
+ // Sub-provider contexts
204
+ const channelCtx = useChannelContext();
205
+ const sendCtx = useSendContext();
206
+
207
+ // In local mode, fetch the project name from the health endpoint so we never show "Dashboard".
208
+ const [localUsername, setLocalUsername] = useState<string | null>(
209
+ typeof window !== 'undefined' ? localStorage.getItem('relay_username') : null
210
+ );
211
+ useEffect(() => {
212
+ const stored = typeof window !== 'undefined' ? localStorage.getItem('relay_username') : null;
213
+ if (stored && stored !== localUsername) {
214
+ setLocalUsername(stored);
215
+ return;
216
+ }
217
+ if (!localUsername) {
218
+ fetch('/api/health')
219
+ .then((res) => res.ok ? res.json() : null)
220
+ .then((data) => {
221
+ if (data?.projectName) {
222
+ localStorage.setItem('relay_username', data.projectName);
223
+ setLocalUsername(data.projectName);
224
+ }
225
+ })
226
+ .catch(() => {});
227
+ }
228
+ });
229
+
230
+ // View mode
231
+ const [viewMode, setViewMode] = useState<'local' | 'fleet' | 'channels'>(
232
+ isWorkspaceFeaturesEnabled ? 'local' : 'channels'
233
+ );
234
+
235
+ // DM state
236
+ const [dmSelectedAgentsByHuman, setDmSelectedAgentsByHuman] = useState<Record<string, string[]>>({});
237
+ const [dmRemovedAgentsByHuman, setDmRemovedAgentsByHuman] = useState<Record<string, string[]>>({});
238
+ const [dmSeenAt, setDmSeenAt] = useState<Map<string, number>>(new Map());
239
+
240
+ // User profile panel state
241
+ const [selectedUserProfile, setSelectedUserProfile] = useState<UserPresence | null>(null);
242
+ const [pendingMention, setPendingMention] = useState<string | undefined>();
243
+
244
+ // Mobile unread tracking
245
+ const [hasUnreadMessages, setHasUnreadMessages] = useState(false);
246
+ const lastSeenMessageCountRef = useRef<number>(0);
247
+ const lastNotifiedMessageIdRef = useRef<string | null>(null);
248
+ const relayRealtimeEnabledRef = useRef(false);
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Presence event handler (used by usePresence and the WebSocket onEvent)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ const handlePresenceEvent = useCallback((event: any) => {
255
+ if (event?.type === 'presence_join' && event.user) {
256
+ const user = event.user;
257
+ if (user.username !== currentUser?.displayName) {
258
+ addActivityEvent({
259
+ type: 'user_joined',
260
+ actor: user.username,
261
+ actorAvatarUrl: user.avatarUrl,
262
+ actorType: 'user',
263
+ title: 'came online',
264
+ });
265
+ }
266
+ } else if (event?.type === 'presence_leave' && event.username) {
267
+ if (event.username !== currentUser?.displayName) {
268
+ addActivityEvent({
269
+ type: 'user_left',
270
+ actor: event.username,
271
+ actorType: 'user',
272
+ title: 'went offline',
273
+ });
274
+ }
275
+ } else if (event?.type === 'agent_spawned' && event.agent) {
276
+ addActivityEvent({
277
+ type: 'agent_spawned',
278
+ actor: event.agent.name || event.agent,
279
+ actorType: 'agent',
280
+ title: 'was spawned',
281
+ description: event.task,
282
+ metadata: { cli: event.cli, task: event.task, spawnedBy: event.spawnedBy },
283
+ });
284
+ } else if (event?.type === 'agent_released' && event.agent) {
285
+ addActivityEvent({
286
+ type: 'agent_released',
287
+ actor: event.agent.name || event.agent,
288
+ actorType: 'agent',
289
+ title: 'was released',
290
+ metadata: { releasedBy: event.releasedBy },
291
+ });
292
+ } else if (event?.type === 'channel_created') {
293
+ const newChannel = event.channel;
294
+ if (!newChannel || !newChannel.id) return;
295
+
296
+ channelCtx.setChannelsList(prev => {
297
+ if (prev.some(c => c.id === newChannel.id)) return prev;
298
+
299
+ const channel: Channel = {
300
+ id: newChannel.id,
301
+ name: newChannel.name || newChannel.id,
302
+ description: newChannel.description,
303
+ visibility: newChannel.visibility || 'public',
304
+ status: newChannel.status || 'active',
305
+ createdAt: newChannel.createdAt || new Date().toISOString(),
306
+ createdBy: newChannel.createdBy || 'unknown',
307
+ memberCount: newChannel.memberCount || 1,
308
+ unreadCount: newChannel.unreadCount || 0,
309
+ hasMentions: newChannel.hasMentions || false,
310
+ isDm: newChannel.isDm || false,
311
+ };
312
+ console.log('[MessageProvider] Channel created via WebSocket:', channel.id);
313
+ return [...prev, channel];
314
+ });
315
+ } else if (event?.type === 'channel_message') {
316
+ if (relayRealtimeEnabledRef.current) return;
317
+ const channelId = event.channel as string | undefined;
318
+ if (!channelId) return;
319
+ const sender = event.from || 'unknown';
320
+ const fromEntityType = event.fromEntityType || (currentUser?.displayName && sender === currentUser.displayName ? 'user' : 'agent');
321
+ const msg: ChannelApiMessage = {
322
+ id: event.id ?? `ws-${Date.now()}`,
323
+ channelId,
324
+ from: sender,
325
+ fromEntityType,
326
+ fromAvatarUrl: event.fromAvatarUrl,
327
+ content: event.body ?? '',
328
+ timestamp: event.timestamp || new Date().toISOString(),
329
+ threadId: event.thread,
330
+ isRead: channelCtx.selectedChannelId === channelId,
331
+ };
332
+ sendCtx.appendChannelMessage(channelId, msg, { incrementUnread: channelCtx.selectedChannelId !== channelId });
333
+ } else if (event?.type === 'direct_message') {
334
+ if (relayRealtimeEnabledRef.current) return;
335
+ const sender = event.from || 'unknown';
336
+ const recipient = currentUser?.displayName || event.targetUser || relayAgentName || 'Dashboard';
337
+
338
+ const participants = [sender, recipient].sort();
339
+ const dmChannelId = `dm:${participants.join(':')}`;
340
+
341
+ const fromEntityType = event.fromEntityType || 'agent';
342
+ const msg: ChannelApiMessage = {
343
+ id: event.id ?? `dm-${Date.now()}`,
344
+ channelId: dmChannelId,
345
+ from: sender,
346
+ fromEntityType,
347
+ fromAvatarUrl: event.fromAvatarUrl,
348
+ content: event.body ?? '',
349
+ timestamp: event.timestamp || new Date().toISOString(),
350
+ threadId: event.thread,
351
+ isRead: channelCtx.selectedChannelId === dmChannelId,
352
+ };
353
+ sendCtx.appendChannelMessage(dmChannelId, msg, { incrementUnread: channelCtx.selectedChannelId !== dmChannelId });
354
+ }
355
+ }, [addActivityEvent, sendCtx.appendChannelMessage, currentUser?.displayName, channelCtx.selectedChannelId, channelCtx.setChannelsList, relayAgentName]);
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Presence
359
+ // ---------------------------------------------------------------------------
360
+
361
+ const presenceUser = useMemo(() =>
362
+ currentUser
363
+ ? { username: currentUser.displayName, avatarUrl: currentUser.avatarUrl }
364
+ : undefined,
365
+ [currentUser?.displayName, currentUser?.avatarUrl]
366
+ );
367
+
368
+ const { onlineUsers: allOnlineUsers, typingUsers, sendTyping, isConnected: isPresenceConnected } = usePresence({
369
+ currentUser: presenceUser,
370
+ onEvent: handlePresenceEvent,
371
+ workspaceId: effectiveActiveWorkspaceId ?? undefined,
372
+ });
373
+
374
+ const onlineUsers = allOnlineUsers;
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Relay DMs and message normalization
378
+ // ---------------------------------------------------------------------------
379
+
380
+ const relayDMsState = useRelayDMs();
381
+ const normalizedRelayMessages = useMemo(() => {
382
+ const sourceMessages = data?.messages ?? [];
383
+ if (!relayConfigured || relayDMsState.conversations.length === 0) {
384
+ return sourceMessages;
385
+ }
386
+ return normalizeRelayDmMessageTargets(sourceMessages, relayDMsState.conversations);
387
+ }, [data?.messages, relayConfigured, relayDMsState.conversations]);
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Core message hook
391
+ // ---------------------------------------------------------------------------
392
+
393
+ const {
394
+ messages,
395
+ threadMessages,
396
+ currentChannel,
397
+ setCurrentChannel,
398
+ currentThread,
399
+ setCurrentThread,
400
+ activeThreads,
401
+ totalUnreadThreadCount,
402
+ sendMessage,
403
+ isSending,
404
+ sendError,
405
+ } = useMessagesHook({
406
+ messages: normalizedRelayMessages,
407
+ senderName: currentUser?.displayName || localUsername || undefined,
408
+ });
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Thread data
412
+ // ---------------------------------------------------------------------------
413
+
414
+ const thread = useThread({
415
+ threadId: viewMode === 'channels'
416
+ ? (relayConfigured ? currentThread : null)
417
+ : currentThread,
418
+ fallbackMessages: messages,
419
+ });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // DM state
423
+ // ---------------------------------------------------------------------------
424
+
425
+ const currentHuman = useMemo(() => {
426
+ if (!currentChannel) return null;
427
+ return combinedAgents.find(
428
+ (a) => a.isHuman && a.name.toLowerCase() === currentChannel.toLowerCase()
429
+ ) || null;
430
+ }, [combinedAgents, currentChannel]);
431
+
432
+ const selectedDmAgents = useMemo(
433
+ () => (currentHuman ? dmSelectedAgentsByHuman[currentHuman.name] ?? [] : []),
434
+ [currentHuman, dmSelectedAgentsByHuman]
435
+ );
436
+ const removedDmAgents = useMemo(
437
+ () => (currentHuman ? dmRemovedAgentsByHuman[currentHuman.name] ?? [] : []),
438
+ [currentHuman, dmRemovedAgentsByHuman]
439
+ );
440
+
441
+ const { visibleMessages: dedupedVisibleMessages, participantAgents: dmParticipantAgents } = useDirectMessage({
442
+ currentHuman,
443
+ currentUserName: currentUser?.displayName ?? null,
444
+ messages,
445
+ agents,
446
+ selectedDmAgents,
447
+ removedDmAgents,
448
+ });
449
+
450
+ useEffect(() => {
451
+ relayRealtimeEnabledRef.current = relayConfigured;
452
+ }, [relayConfigured]);
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Human users extraction
456
+ // ---------------------------------------------------------------------------
457
+
458
+ const humanUsers = useMemo((): HumanUser[] => {
459
+ const agentNames = new Set(agents.filter((a) => !a.isHuman).map((a) => a.name.toLowerCase()));
460
+ const seenUsers = new Map<string, HumanUser>();
461
+
462
+ if (currentUser) {
463
+ seenUsers.set(currentUser.displayName.toLowerCase(), {
464
+ username: currentUser.displayName,
465
+ avatarUrl: currentUser.avatarUrl,
466
+ });
467
+ }
468
+
469
+ if (relayConfigured && relayDMsState.conversations.length > 0) {
470
+ const currentUserName = currentUser?.displayName.toLowerCase();
471
+ for (const conversation of relayDMsState.conversations) {
472
+ for (const participant of conversation.participants) {
473
+ const name = typeof participant === 'string' ? participant : participant.agentName;
474
+ if (!name) continue;
475
+ const lowered = name.toLowerCase();
476
+ if (currentUserName && lowered === currentUserName) continue;
477
+ if (agentNames.has(lowered)) continue;
478
+ if (!seenUsers.has(lowered)) {
479
+ seenUsers.set(lowered, { username: name });
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ for (const msg of normalizedRelayMessages) {
486
+ const sender = msg.from;
487
+ if (sender && isHumanSender(sender, agentNames, relayAgentName) && !seenUsers.has(sender.toLowerCase())) {
488
+ seenUsers.set(sender.toLowerCase(), { username: sender });
489
+ }
490
+ }
491
+
492
+ return Array.from(seenUsers.values());
493
+ }, [normalizedRelayMessages, agents, currentUser, relayDMsState.conversations, relayConfigured, relayAgentName]);
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Human unread counts
497
+ // ---------------------------------------------------------------------------
498
+
499
+ const humanUnreadCounts = useMemo(() => {
500
+ if (!currentUser) return {};
501
+
502
+ if (relayConfigured && relayDMsState.conversations.length > 0) {
503
+ const counts: Record<string, number> = {};
504
+ const currentUserName = currentUser.displayName.toLowerCase();
505
+ const agentNames = new Set(agents.filter((a) => !a.isHuman).map((a) => a.name.toLowerCase()));
506
+
507
+ for (const conversation of relayDMsState.conversations) {
508
+ if (!conversation.unreadCount) continue;
509
+
510
+ const match = conversation.participants.find((p) => {
511
+ const name = typeof p === 'string' ? p : p.agentName;
512
+ if (!name) return false;
513
+ const lowered = name.toLowerCase();
514
+ return lowered !== currentUserName && !agentNames.has(lowered);
515
+ });
516
+ const participantName = match ? (typeof match === 'string' ? match : match.agentName) : null;
517
+
518
+ if (participantName) {
519
+ counts[participantName] = (counts[participantName] || 0) + conversation.unreadCount;
520
+ }
521
+ }
522
+
523
+ return counts;
524
+ }
525
+
526
+ const counts: Record<string, number> = {};
527
+ const humanNameSet = new Set(
528
+ combinedAgents.filter((a) => a.isHuman).map((a) => a.name.toLowerCase())
529
+ );
530
+
531
+ for (const msg of normalizedRelayMessages) {
532
+ const sender = msg.from;
533
+ const recipient = msg.to;
534
+ if (!sender || !recipient) continue;
535
+
536
+ const isToCurrentUser = recipient === currentUser.displayName;
537
+ const senderIsHuman = humanNameSet.has(sender.toLowerCase());
538
+ if (!isToCurrentUser || !senderIsHuman) continue;
539
+
540
+ const seenAt = dmSeenAt.get(sender.toLowerCase()) ?? 0;
541
+ const ts = new Date(msg.timestamp).getTime();
542
+ if (ts > seenAt) {
543
+ counts[sender] = (counts[sender] || 0) + 1;
544
+ }
545
+ }
546
+
547
+ return counts;
548
+ }, [combinedAgents, currentUser, normalizedRelayMessages, dmSeenAt, relayDMsState.conversations, agents, relayConfigured]);
549
+
550
+ const markDmSeen = useCallback((username: string) => {
551
+ setDmSeenAt((prev) => {
552
+ const next = new Map(prev);
553
+ next.set(username.toLowerCase(), Date.now());
554
+ return next;
555
+ });
556
+ }, []);
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // DM handlers
560
+ // ---------------------------------------------------------------------------
561
+
562
+ const handleDmAgentToggle = useCallback((agentName: string) => {
563
+ if (!currentHuman) return;
564
+ const humanName = currentHuman.name;
565
+ const isSelected = (dmSelectedAgentsByHuman[humanName] ?? []).includes(agentName);
566
+
567
+ setDmSelectedAgentsByHuman((prev) => {
568
+ const currentList = prev[humanName] ?? [];
569
+ const nextList = isSelected
570
+ ? currentList.filter((a) => a !== agentName)
571
+ : [...currentList, agentName];
572
+ return { ...prev, [humanName]: nextList };
573
+ });
574
+
575
+ setDmRemovedAgentsByHuman((prev) => {
576
+ const currentList = prev[humanName] ?? [];
577
+ if (isSelected) {
578
+ return currentList.includes(agentName)
579
+ ? prev
580
+ : { ...prev, [humanName]: [...currentList, agentName] };
581
+ }
582
+ return { ...prev, [humanName]: currentList.filter((a) => a !== agentName) };
583
+ });
584
+ }, [currentHuman, dmSelectedAgentsByHuman]);
585
+
586
+ const handleDmSend = useCallback(async (content: string, attachmentIds?: string[]): Promise<boolean> => {
587
+ if (!currentHuman) return false;
588
+ const humanName = currentHuman.name;
589
+
590
+ await sendMessage(humanName, content, undefined, attachmentIds);
591
+
592
+ if (selectedDmAgents.length > 0) {
593
+ for (const agent of selectedDmAgents) {
594
+ await sendMessage(agent, content, undefined, attachmentIds);
595
+ }
596
+ }
597
+
598
+ return true;
599
+ }, [currentHuman, selectedDmAgents, sendMessage]);
600
+
601
+ const handleMainComposerSend = useCallback(
602
+ async (content: string, attachmentIds?: string[]) => {
603
+ if (currentHuman) {
604
+ return handleDmSend(content, attachmentIds);
605
+ }
606
+ return sendMessage(currentChannel, content, undefined, attachmentIds);
607
+ },
608
+ [currentChannel, currentHuman, handleDmSend, sendMessage]
609
+ );
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Browser notifications and sounds
613
+ // ---------------------------------------------------------------------------
614
+
615
+ useEffect(() => {
616
+ const msgs = normalizedRelayMessages;
617
+ if (!msgs || msgs.length === 0) {
618
+ lastNotifiedMessageIdRef.current = null;
619
+ return;
620
+ }
621
+
622
+ const latestMessage = msgs[msgs.length - 1];
623
+
624
+ if (!settings.notifications.enabled) {
625
+ lastNotifiedMessageIdRef.current = latestMessage?.id ?? null;
626
+ return;
627
+ }
628
+
629
+ if (!lastNotifiedMessageIdRef.current) {
630
+ lastNotifiedMessageIdRef.current = latestMessage.id;
631
+ return;
632
+ }
633
+
634
+ const lastNotifiedIndex = msgs.findIndex((message) => (
635
+ message.id === lastNotifiedMessageIdRef.current
636
+ ));
637
+
638
+ if (lastNotifiedIndex === -1) {
639
+ lastNotifiedMessageIdRef.current = latestMessage.id;
640
+ return;
641
+ }
642
+
643
+ const newMessages = msgs.slice(lastNotifiedIndex + 1);
644
+ if (newMessages.length === 0) return;
645
+
646
+ lastNotifiedMessageIdRef.current = latestMessage.id;
647
+
648
+ const isFromCurrentUser = (message: Message) =>
649
+ message.from === 'Dashboard' ||
650
+ message.from === relayAgentName ||
651
+ (currentUser && message.from === currentUser.displayName);
652
+
653
+ const isMessageInCurrentChannel = (message: Message) => {
654
+ return message.from === currentChannel || message.to === currentChannel;
655
+ };
656
+
657
+ const shouldNotifyForMessage = (message: Message) => {
658
+ if (isFromCurrentUser(message)) return false;
659
+ if (settings.notifications.mentionsOnly && currentUser?.displayName) {
660
+ if (!message.content.includes(`@${currentUser.displayName}`)) return false;
661
+ }
662
+ const isActive = typeof document !== 'undefined' ? !document.hidden : false;
663
+ if (isActive && isMessageInCurrentChannel(message)) return false;
664
+ return true;
665
+ };
666
+
667
+ let shouldPlaySound = false;
668
+
669
+ for (const message of newMessages) {
670
+ if (!shouldNotifyForMessage(message)) continue;
671
+
672
+ if (settings.notifications.desktop && typeof window !== 'undefined' && 'Notification' in window) {
673
+ if (Notification.permission === 'granted') {
674
+ const channelLabel = message.to;
675
+ const body = message.content.split('\n')[0].slice(0, 160);
676
+ const notification = new Notification(`${message.from} -> ${channelLabel}`, { body });
677
+ notification.onclick = () => {
678
+ window.focus();
679
+ setCurrentChannel(message.from);
680
+ notification.close();
681
+ };
682
+ }
683
+ }
684
+
685
+ if (settings.notifications.sound) {
686
+ shouldPlaySound = true;
687
+ }
688
+ }
689
+
690
+ if (shouldPlaySound) {
691
+ playNotificationSound();
692
+ }
693
+ }, [normalizedRelayMessages, settings.notifications, currentChannel, currentUser, setCurrentChannel, relayAgentName]);
694
+
695
+ // Mobile unread tracking
696
+ useEffect(() => {
697
+ lastSeenMessageCountRef.current = messages.length;
698
+ // eslint-disable-next-line react-hooks/exhaustive-deps
699
+ }, []);
700
+
701
+ // ---------------------------------------------------------------------------
702
+ // Compose context value (merging sub-provider values for backward compat)
703
+ // ---------------------------------------------------------------------------
704
+
705
+ const value = useMemo<MessageContextValue>(() => ({
706
+ // Core message state
707
+ messages, threadMessages, currentChannel, setCurrentChannel,
708
+ currentThread, setCurrentThread, activeThreads, totalUnreadThreadCount,
709
+ sendMessage, isSending, sendError, thread,
710
+
711
+ // View mode
712
+ viewMode, setViewMode,
713
+
714
+ // Channel state (from ChannelProvider)
715
+ channelsList: channelCtx.channelsList,
716
+ archivedChannelsList: channelCtx.archivedChannelsList,
717
+ selectedChannelId: channelCtx.selectedChannelId,
718
+ setSelectedChannelId: channelCtx.setSelectedChannelId,
719
+ selectedChannel: channelCtx.selectedChannel,
720
+ isChannelsLoading: channelCtx.isChannelsLoading,
721
+
722
+ // Channel message state (from SendProvider)
723
+ channelMessages: sendCtx.channelMessages,
724
+ hasMoreMessages: sendCtx.hasMoreMessages,
725
+ channelUnreadState: sendCtx.channelUnreadState,
726
+ effectiveChannelMessages: sendCtx.effectiveChannelMessages,
727
+
728
+ // Channel handlers (from ChannelProvider)
729
+ handleSelectChannel: channelCtx.handleSelectChannel,
730
+ handleCreateChannel: channelCtx.handleCreateChannel,
731
+ handleCreateChannelSubmit: channelCtx.handleCreateChannelSubmit,
732
+ handleInviteToChannel: channelCtx.handleInviteToChannel,
733
+ handleInviteSubmit: channelCtx.handleInviteSubmit,
734
+ handleJoinChannel: channelCtx.handleJoinChannel,
735
+ handleLeaveChannel: channelCtx.handleLeaveChannel,
736
+ handleShowMembers: channelCtx.handleShowMembers,
737
+ handleRemoveMember: channelCtx.handleRemoveMember,
738
+ handleAddMember: channelCtx.handleAddMember,
739
+ handleArchiveChannel: channelCtx.handleArchiveChannel,
740
+ handleUnarchiveChannel: channelCtx.handleUnarchiveChannel,
741
+
742
+ // Send handlers (from SendProvider)
743
+ handleSendChannelMessage: sendCtx.handleSendChannelMessage,
744
+ handleLoadMoreMessages: sendCtx.handleLoadMoreMessages,
745
+ handleMarkChannelRead: sendCtx.handleMarkChannelRead,
746
+ handleReaction: sendCtx.handleReaction,
747
+
748
+ // Channel modals (from ChannelProvider)
749
+ isCreateChannelOpen: channelCtx.isCreateChannelOpen,
750
+ setIsCreateChannelOpen: channelCtx.setIsCreateChannelOpen,
751
+ isCreatingChannel: channelCtx.isCreatingChannel,
752
+ isInviteChannelOpen: channelCtx.isInviteChannelOpen,
753
+ setIsInviteChannelOpen: channelCtx.setIsInviteChannelOpen,
754
+ inviteChannelTarget: channelCtx.inviteChannelTarget,
755
+ setInviteChannelTarget: channelCtx.setInviteChannelTarget,
756
+ isInvitingToChannel: channelCtx.isInvitingToChannel,
757
+ showMemberPanel: channelCtx.showMemberPanel,
758
+ setShowMemberPanel: channelCtx.setShowMemberPanel,
759
+ channelMembers: channelCtx.channelMembers,
760
+
761
+ // DM state
762
+ currentHuman, selectedDmAgents, removedDmAgents,
763
+ dedupedVisibleMessages, dmParticipantAgents,
764
+ dmSelectedAgentsByHuman, handleDmAgentToggle,
765
+ handleDmSend, handleMainComposerSend,
766
+
767
+ // Presence
768
+ onlineUsers, typingUsers, sendTyping, isPresenceConnected,
769
+
770
+ // Human users
771
+ humanUsers, humanUnreadCounts,
772
+
773
+ // DM tracking
774
+ markDmSeen,
775
+
776
+ // User profile
777
+ selectedUserProfile, setSelectedUserProfile,
778
+ pendingMention, setPendingMention,
779
+
780
+ // Notification state
781
+ hasUnreadMessages,
782
+
783
+ // WebSocket event handler
784
+ handlePresenceEvent,
785
+
786
+ // External channel updates
787
+ setChannelsList: channelCtx.setChannelsList,
788
+ appendChannelMessage: sendCtx.appendChannelMessage,
789
+ }), [
790
+ messages, threadMessages, currentChannel, setCurrentChannel,
791
+ currentThread, setCurrentThread, activeThreads, totalUnreadThreadCount,
792
+ sendMessage, isSending, sendError, thread,
793
+ viewMode,
794
+ channelCtx,
795
+ sendCtx,
796
+ currentHuman, selectedDmAgents, removedDmAgents,
797
+ dedupedVisibleMessages, dmParticipantAgents,
798
+ dmSelectedAgentsByHuman, handleDmAgentToggle,
799
+ handleDmSend, handleMainComposerSend,
800
+ onlineUsers, typingUsers, sendTyping, isPresenceConnected,
801
+ humanUsers, humanUnreadCounts,
802
+ markDmSeen,
803
+ selectedUserProfile,
804
+ pendingMention,
805
+ hasUnreadMessages, handlePresenceEvent,
806
+ ]);
807
+
808
+ return (
809
+ <MessageContext.Provider value={value}>
810
+ {children}
811
+ </MessageContext.Provider>
812
+ );
813
+ }
814
+
815
+ // ---------------------------------------------------------------------------
816
+ // Outer Provider (composes ChannelProvider + SendProvider + MessageProviderInner)
817
+ // ---------------------------------------------------------------------------
818
+
819
+ export function MessageProvider({ children, data, rawData, enableReactions = false }: MessageProviderProps) {
820
+ return (
821
+ <ChannelProvider>
822
+ <MessageProviderInnerWithSend data={data} rawData={rawData} enableReactions={enableReactions}>
823
+ {children}
824
+ </MessageProviderInnerWithSend>
825
+ </ChannelProvider>
826
+ );
827
+ }
828
+
829
+ /**
830
+ * Intermediate wrapper that creates the SendProvider with the local messages
831
+ * that MessageProviderInner will compute. Since SendProvider needs messages
832
+ * from the useMessagesHook (which lives inside MessageProviderInner), we pass
833
+ * them as empty and let SendProvider handle its own message loading.
834
+ */
835
+ function MessageProviderInnerWithSend({ children, data, rawData, enableReactions }: MessageProviderInnerProps) {
836
+ // We need to pass localMessages to SendProvider for the local channel message fallback.
837
+ // However, the normalized messages are computed inside MessageProviderInner.
838
+ // Since SendProvider only needs them for local (non-cloud) channel message rendering,
839
+ // we derive them here at this level too.
840
+ const { configured: relayConfigured } = useRelayConfigStatus();
841
+ const relayDMsState = useRelayDMs();
842
+ const { currentUser } = useCloudWorkspace();
843
+
844
+ const normalizedRelayMessages = useMemo(() => {
845
+ const sourceMessages = data?.messages ?? [];
846
+ if (!relayConfigured || relayDMsState.conversations.length === 0) {
847
+ return sourceMessages;
848
+ }
849
+ return normalizeRelayDmMessageTargets(sourceMessages, relayDMsState.conversations);
850
+ }, [data?.messages, relayConfigured, relayDMsState.conversations]);
851
+
852
+ const [localUsername] = useState<string | null>(
853
+ typeof window !== 'undefined' ? localStorage.getItem('relay_username') : null
854
+ );
855
+
856
+ return (
857
+ <SendProvider localMessages={normalizedRelayMessages} localUsername={localUsername}>
858
+ <MessageProviderInner data={data} rawData={rawData} enableReactions={enableReactions}>
859
+ {children}
860
+ </MessageProviderInner>
861
+ </SendProvider>
862
+ );
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // Hook
867
+ // ---------------------------------------------------------------------------
868
+
869
+ export function useMessageContext(): MessageContextValue {
870
+ const ctx = useContext(MessageContext);
871
+ if (!ctx) {
872
+ throw new Error('useMessageContext must be used within a MessageProvider');
873
+ }
874
+ return ctx;
875
+ }