@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,725 @@
1
+ /**
2
+ * MessageList Component - Mission Control Theme
3
+ *
4
+ * Displays a list of messages with threading support,
5
+ * provider-colored icons, and From → To format.
6
+ */
7
+
8
+ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
9
+ import { ACTIVITY_FEED_ID } from './App';
10
+ import type { Message, Agent, Attachment, Reaction } from '../types';
11
+ import type { UserPresence } from './hooks/usePresence';
12
+ import { MessageStatusIndicator } from './MessageStatusIndicator';
13
+ import { ThinkingIndicator } from './ThinkingIndicator';
14
+ import { MessageSenderName } from './MessageSenderName';
15
+ import { formatMessageBody } from './utils/messageFormatting';
16
+ import { AgentLogPreview } from './AgentLogPreview';
17
+ import { ReactionChips } from './ReactionChips';
18
+
19
+ // Provider icons and colors matching landing page
20
+ const PROVIDER_CONFIG: Record<string, { icon: string; color: string }> = {
21
+ claude: { icon: '◈', color: '#00d9ff' },
22
+ codex: { icon: '⬡', color: '#ff6b35' },
23
+ gemini: { icon: '◇', color: '#a855f7' },
24
+ openai: { icon: '◆', color: '#10a37f' },
25
+ default: { icon: '●', color: '#00d9ff' },
26
+ };
27
+
28
+ // Get provider config from agent name (heuristic-based)
29
+ function getProviderConfig(agentName: string): { icon: string; color: string } {
30
+ const nameLower = agentName.toLowerCase();
31
+ if (nameLower.includes('claude') || nameLower.includes('anthropic')) {
32
+ return PROVIDER_CONFIG.claude;
33
+ }
34
+ if (nameLower.includes('codex') || nameLower.includes('openai') || nameLower.includes('gpt')) {
35
+ return PROVIDER_CONFIG.codex;
36
+ }
37
+ if (nameLower.includes('gemini') || nameLower.includes('google') || nameLower.includes('bard')) {
38
+ return PROVIDER_CONFIG.gemini;
39
+ }
40
+ // Default: cycle through colors based on name hash
41
+ const hash = agentName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
42
+ const providers = Object.keys(PROVIDER_CONFIG).filter((k) => k !== 'default');
43
+ const provider = providers[hash % providers.length];
44
+ return PROVIDER_CONFIG[provider];
45
+ }
46
+
47
+ /** Current user info for displaying avatar/username */
48
+ export interface CurrentUser {
49
+ displayName: string;
50
+ avatarUrl?: string;
51
+ }
52
+
53
+ export interface MessageListProps {
54
+ messages: Message[];
55
+ currentChannel: string;
56
+ onThreadClick?: (messageId: string) => void;
57
+ highlightedMessageId?: string;
58
+ /** Currently selected thread ID - when set, shows thread-related messages */
59
+ currentThread?: string | null;
60
+ /** Agents list for checking processing state */
61
+ agents?: Agent[];
62
+ /** Current user info (for cloud mode - shows avatar/username instead of "Dashboard") */
63
+ currentUser?: CurrentUser;
64
+ /** Skip channel filtering - messages are already filtered (for DM views) */
65
+ skipChannelFilter?: boolean;
66
+ /** Default auto-scroll preference */
67
+ autoScrollDefault?: boolean;
68
+ /** Show timestamps in message header */
69
+ showTimestamps?: boolean;
70
+ /** Compact spacing for dense layouts */
71
+ compactMode?: boolean;
72
+ /** Callback when an agent name is clicked to open profile */
73
+ onAgentClick?: (agent: Agent) => void;
74
+ /** Callback when a human user name is clicked to open profile */
75
+ onUserClick?: (user: UserPresence) => void;
76
+ /** Callback when logs should open for an agent */
77
+ onLogsClick?: (agent: Agent) => void;
78
+ /** Online users list for profile lookup */
79
+ onlineUsers?: UserPresence[];
80
+ /** Callback when a reaction is toggled on a message */
81
+ onReaction?: (messageId: string, emoji: string, hasReacted: boolean) => void;
82
+ }
83
+
84
+ export function MessageList({
85
+ messages,
86
+ currentChannel,
87
+ onThreadClick,
88
+ highlightedMessageId,
89
+ currentThread,
90
+ agents = [],
91
+ currentUser,
92
+ skipChannelFilter = false,
93
+ autoScrollDefault = true,
94
+ showTimestamps = true,
95
+ compactMode = false,
96
+ onAgentClick,
97
+ onUserClick,
98
+ onLogsClick,
99
+ onlineUsers = [],
100
+ onReaction,
101
+ }: MessageListProps) {
102
+ // Build a map of agent name -> processing state for quick lookup
103
+ const processingAgents = new Map<string, { isProcessing: boolean; processingStartedAt?: number }>();
104
+ for (const agent of agents) {
105
+ if (agent.isProcessing) {
106
+ processingAgents.set(agent.name, {
107
+ isProcessing: true,
108
+ processingStartedAt: agent.processingStartedAt,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Build a map of recipient -> latest message ID from current user
114
+ // This is used to only show the thinking indicator on the most recent message
115
+ const latestMessageToAgent = new Map<string, string>();
116
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
117
+ const contentRef = useRef<HTMLDivElement>(null);
118
+ const [autoScroll, setAutoScroll] = useState(autoScrollDefault);
119
+ const prevFilteredLengthRef = useRef<number>(0);
120
+ const prevChannelRef = useRef<string>(currentChannel);
121
+ // Track if we should scroll on next render (set before DOM updates)
122
+ const shouldScrollRef = useRef(false);
123
+ // Track if a scroll is in progress to prevent race conditions
124
+ const isScrollingRef = useRef(false);
125
+ const resizeScrollRafRef = useRef<number | null>(null);
126
+
127
+ useEffect(() => {
128
+ setAutoScroll(autoScrollDefault);
129
+ }, [autoScrollDefault]);
130
+
131
+ // Filter messages for current channel or current thread
132
+ const channelFilteredMessages = messages.filter((msg) => {
133
+ // When a thread is selected, show messages related to that thread
134
+ if (currentThread) {
135
+ // Show the original message (id matches thread) or replies (thread field matches)
136
+ return msg.id === currentThread || msg.thread === currentThread;
137
+ }
138
+
139
+ // Skip channel filtering if messages are already filtered (e.g., DM views)
140
+ if (skipChannelFilter) {
141
+ return true;
142
+ }
143
+
144
+ // Activity feed shows activity events (handled by ActivityFeed component), not messages
145
+ if (currentChannel === ACTIVITY_FEED_ID) {
146
+ return false;
147
+ }
148
+ // #general channel - treat as normal channel
149
+ if (currentChannel === '#general') {
150
+ return msg.channel === 'general' || msg.channel === '#general' ||
151
+ msg.to === '#general';
152
+ }
153
+ return msg.from === currentChannel || msg.to === currentChannel;
154
+ });
155
+
156
+ const filteredMessages = channelFilteredMessages;
157
+
158
+ // Populate latestMessageToAgent with the latest message from current user to each agent
159
+ // Iterate in order (oldest to newest) so the last one wins
160
+ for (const msg of filteredMessages) {
161
+ const isFromCurrentUser = msg.from === 'Dashboard' ||
162
+ (currentUser && msg.from === currentUser.displayName);
163
+ if (isFromCurrentUser && msg.to !== '*') {
164
+ latestMessageToAgent.set(msg.to, msg.id);
165
+ }
166
+ }
167
+
168
+ const autoScrollAllowed = autoScrollDefault;
169
+
170
+ // Check if we need to scroll BEFORE the DOM updates
171
+ // This runs during render, before useLayoutEffect
172
+ const currentLength = filteredMessages.length;
173
+ if (currentLength > prevFilteredLengthRef.current) {
174
+ // Check if the latest message is from the current user
175
+ // This includes both "Dashboard" (local mode) and GitHub username (cloud mode)
176
+ // Scroll for user's own messages only when auto-scroll is enabled
177
+ const latestMessage = filteredMessages[filteredMessages.length - 1];
178
+ const latestIsFromUser = latestMessage?.from === 'Dashboard' ||
179
+ (currentUser && latestMessage?.from === currentUser.displayName);
180
+
181
+ if (autoScrollAllowed && (latestIsFromUser || autoScroll)) {
182
+ shouldScrollRef.current = true;
183
+ // Re-enable auto-scroll if we're scrolling for user's message
184
+ // This ensures continued auto-scroll after user sends a message
185
+ if (latestIsFromUser && !autoScroll) {
186
+ setAutoScroll(true);
187
+ }
188
+ }
189
+ }
190
+ prevFilteredLengthRef.current = currentLength;
191
+
192
+ // Handle scroll to detect manual scroll (disable/enable auto-scroll)
193
+ const handleScroll = useCallback(() => {
194
+ if (!scrollContainerRef.current) return;
195
+ // Skip scroll events that happen during programmatic scrolling
196
+ if (isScrollingRef.current) return;
197
+ if (!autoScrollDefault) return;
198
+
199
+ const container = scrollContainerRef.current;
200
+ const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
201
+ const isAtBottom = distanceFromBottom < 50;
202
+
203
+ // Re-enable auto-scroll when user scrolls to bottom
204
+ if (isAtBottom && !autoScroll) {
205
+ setAutoScroll(true);
206
+ }
207
+ // Disable auto-scroll when user scrolls significantly away from bottom
208
+ // Use a larger threshold to avoid false disables from small layout shifts
209
+ else if (distanceFromBottom > 150 && autoScroll) {
210
+ setAutoScroll(false);
211
+ }
212
+ }, [autoScroll]);
213
+
214
+ // Keep the view pinned to the bottom while auto-scroll is enabled, even when content grows
215
+ // without new messages (e.g. log previews expanding from 1->5 lines, images loading).
216
+ useEffect(() => {
217
+ if (!autoScrollDefault) return;
218
+ if (!autoScroll) return;
219
+ if (typeof ResizeObserver === 'undefined') return;
220
+
221
+ const content = contentRef.current;
222
+ if (!content) return;
223
+
224
+ const ro = new ResizeObserver(() => {
225
+ if (!autoScroll || !scrollContainerRef.current) return;
226
+
227
+ // Batch multiple ResizeObserver events into a single RAF.
228
+ if (resizeScrollRafRef.current !== null) return;
229
+ resizeScrollRafRef.current = requestAnimationFrame(() => {
230
+ resizeScrollRafRef.current = null;
231
+ if (!autoScroll || !scrollContainerRef.current) return;
232
+
233
+ isScrollingRef.current = true;
234
+ const container = scrollContainerRef.current;
235
+ container.scrollTop = container.scrollHeight;
236
+
237
+ requestAnimationFrame(() => {
238
+ setTimeout(() => {
239
+ isScrollingRef.current = false;
240
+ }, 50);
241
+ });
242
+ });
243
+ });
244
+
245
+ ro.observe(content);
246
+
247
+ return () => {
248
+ ro.disconnect();
249
+ if (resizeScrollRafRef.current !== null) {
250
+ cancelAnimationFrame(resizeScrollRafRef.current);
251
+ resizeScrollRafRef.current = null;
252
+ }
253
+ };
254
+ }, [autoScroll, autoScrollDefault]);
255
+
256
+ // Auto-scroll to bottom when new messages arrive - use useLayoutEffect for immediate execution
257
+ useLayoutEffect(() => {
258
+ if (shouldScrollRef.current && scrollContainerRef.current) {
259
+ shouldScrollRef.current = false;
260
+ isScrollingRef.current = true;
261
+
262
+ const container = scrollContainerRef.current;
263
+ container.scrollTop = container.scrollHeight;
264
+
265
+ // Clear the scrolling flag after the scroll event has been processed
266
+ requestAnimationFrame(() => {
267
+ setTimeout(() => {
268
+ isScrollingRef.current = false;
269
+ }, 50);
270
+ });
271
+ }
272
+ }, [filteredMessages.length]);
273
+
274
+ // Reset scroll position and auto-scroll when channel changes
275
+ useLayoutEffect(() => {
276
+ if (currentChannel !== prevChannelRef.current) {
277
+ prevChannelRef.current = currentChannel;
278
+ prevFilteredLengthRef.current = filteredMessages.length;
279
+ setAutoScroll(true);
280
+
281
+ // Scroll to bottom on channel change
282
+ if (scrollContainerRef.current) {
283
+ isScrollingRef.current = true;
284
+ const container = scrollContainerRef.current;
285
+ container.scrollTop = container.scrollHeight;
286
+
287
+ // Clear the scrolling flag after the scroll event has been processed
288
+ requestAnimationFrame(() => {
289
+ setTimeout(() => {
290
+ isScrollingRef.current = false;
291
+ }, 50);
292
+ });
293
+ }
294
+ }
295
+ }, [currentChannel, filteredMessages.length]);
296
+
297
+ if (filteredMessages.length === 0) {
298
+ return (
299
+ <div className="flex flex-col items-center justify-center h-full text-text-muted text-center">
300
+ <EmptyIcon />
301
+ <h3 className="m-0 mb-2 text-base font-display text-text-secondary">No messages yet</h3>
302
+ <p className="m-0 text-sm">
303
+ {`Messages in ${currentChannel} will appear here`}
304
+ </p>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ return (
310
+ <div
311
+ className="bg-bg-secondary h-full overflow-y-auto"
312
+ ref={scrollContainerRef}
313
+ onScroll={handleScroll}
314
+ >
315
+ <div
316
+ ref={contentRef}
317
+ className={`flex flex-col ${
318
+ compactMode ? 'gap-0 p-1 sm:p-1.5' : 'gap-2 p-3 sm:p-4'
319
+ }`}
320
+ >
321
+ {filteredMessages.map((message) => {
322
+ // Check if message is from current user (Dashboard or GitHub username)
323
+ const isFromCurrentUser = message.from === 'Dashboard' ||
324
+ (currentUser && message.from === currentUser.displayName);
325
+
326
+ // Check if this is the latest message from current user to this recipient
327
+ // Only the latest message should show the thinking indicator
328
+ const isLatestToRecipient = isFromCurrentUser && message.to !== '*' &&
329
+ latestMessageToAgent.get(message.to) === message.id;
330
+
331
+ // Check if the recipient is currently processing
332
+ // Only show thinking indicator for the LATEST message from current user to an agent
333
+ const recipientProcessing = isLatestToRecipient
334
+ ? processingAgents.get(message.to)
335
+ : undefined;
336
+
337
+ return (
338
+ <MessageItem
339
+ key={message.id}
340
+ message={message}
341
+ isHighlighted={message.id === highlightedMessageId}
342
+ onThreadClick={onThreadClick}
343
+ recipientProcessing={recipientProcessing}
344
+ currentUser={currentUser}
345
+ showTimestamps={showTimestamps}
346
+ compactMode={compactMode}
347
+ agents={agents}
348
+ onlineUsers={onlineUsers}
349
+ onAgentClick={onAgentClick}
350
+ onUserClick={onUserClick}
351
+ onLogsClick={onLogsClick}
352
+ onReaction={onReaction}
353
+ />
354
+ );
355
+ })}
356
+ </div>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ interface MessageItemProps {
362
+ message: Message;
363
+ isHighlighted?: boolean;
364
+ onThreadClick?: (messageId: string) => void;
365
+ /** Processing state of the recipient agent (for showing thinking indicator) */
366
+ recipientProcessing?: { isProcessing: boolean; processingStartedAt?: number };
367
+ /** Current user info for displaying avatar/username */
368
+ currentUser?: CurrentUser;
369
+ showTimestamps?: boolean;
370
+ compactMode?: boolean;
371
+ /** All agents for name lookup */
372
+ agents?: Agent[];
373
+ /** Online users for profile lookup */
374
+ onlineUsers?: UserPresence[];
375
+ /** Callback when an agent name is clicked */
376
+ onAgentClick?: (agent: Agent) => void;
377
+ /** Callback when a user name is clicked */
378
+ onUserClick?: (user: UserPresence) => void;
379
+ /** Callback when logs should open for an agent */
380
+ onLogsClick?: (agent: Agent) => void;
381
+ /** Callback when a reaction is toggled */
382
+ onReaction?: (messageId: string, emoji: string, hasReacted: boolean) => void;
383
+ }
384
+
385
+ function MessageItem({
386
+ message,
387
+ isHighlighted,
388
+ onThreadClick,
389
+ recipientProcessing,
390
+ currentUser,
391
+ showTimestamps = true,
392
+ compactMode = false,
393
+ agents = [],
394
+ onlineUsers = [],
395
+ onAgentClick,
396
+ onUserClick,
397
+ onLogsClick,
398
+ onReaction,
399
+ }: MessageItemProps) {
400
+ const timestamp = formatTimestamp(message.timestamp);
401
+
402
+ // Check if this message is from the current user (Dashboard or their GitHub username)
403
+ const isFromCurrentUser = message.from === 'Dashboard' ||
404
+ (currentUser && message.from === currentUser.displayName);
405
+
406
+ // Get provider config for agent messages, or use user styling for current user
407
+ const provider = isFromCurrentUser && currentUser
408
+ ? { icon: '', color: '#a855f7' } // Purple for user messages
409
+ : getProviderConfig(message.from);
410
+
411
+ // Display name: use GitHub username if available, otherwise message.from
412
+ const displayName = isFromCurrentUser && currentUser
413
+ ? currentUser.displayName
414
+ : message.from;
415
+ const replyCount = message.threadSummary?.replyCount ?? message.replyCount ?? 0;
416
+ const hasReplies = replyCount > 0;
417
+
418
+ // Look up agent or user for sender (for clickable profile)
419
+ const senderAgent = agents.find(a => a.name.toLowerCase() === message.from.toLowerCase() && !a.isHuman);
420
+ const senderUser = onlineUsers.find(u => u.username.toLowerCase() === message.from.toLowerCase());
421
+
422
+ // Look up agent or user for recipient (for clickable profile)
423
+ const recipientAgent = message.to !== '*' ? agents.find(a => a.name.toLowerCase() === message.to.toLowerCase() && !a.isHuman) : undefined;
424
+ const recipientUser = message.to !== '*' ? onlineUsers.find(u => u.username.toLowerCase() === message.to.toLowerCase()) : undefined;
425
+ const recipientProviderConfig = recipientAgent ? getProviderConfig(message.to) : undefined;
426
+
427
+ // Show thinking indicator when:
428
+ // 1. Message is from Dashboard or current user (user sent it)
429
+ // 2. Message has been delivered (acked)
430
+ // 3. Recipient is currently processing
431
+ const showThinking = isFromCurrentUser &&
432
+ (message.status === 'acked' || message.status === 'read') &&
433
+ recipientProcessing?.isProcessing;
434
+
435
+ return (
436
+ <div
437
+ className={`
438
+ group flex rounded-xl transition-all duration-150
439
+ ${compactMode ? 'gap-1.5 py-1 px-1.5' : 'gap-3 sm:gap-4 py-2.5 sm:py-3 px-3 sm:px-4'}
440
+ hover:bg-bg-card/50
441
+ ${isHighlighted ? 'bg-warning-light/20 border-l-2 border-l-warning pl-2 sm:pl-3' : ''}
442
+ `}
443
+ >
444
+ {/* Avatar/Icon */}
445
+ {isFromCurrentUser && currentUser?.avatarUrl ? (
446
+ <img
447
+ src={currentUser.avatarUrl}
448
+ alt={displayName}
449
+ className={`shrink-0 rounded-lg sm:rounded-xl border-2 object-cover ${
450
+ compactMode ? 'w-6 h-6 sm:w-7 sm:h-7' : 'w-9 h-9 sm:w-10 sm:h-10'
451
+ }`}
452
+ style={{
453
+ borderColor: provider.color,
454
+ boxShadow: `0 0 16px ${provider.color}30`,
455
+ }}
456
+ />
457
+ ) : senderUser?.avatarUrl ? (
458
+ <img
459
+ src={senderUser.avatarUrl}
460
+ alt={displayName}
461
+ className={`shrink-0 rounded-lg sm:rounded-xl border-2 object-cover ${
462
+ compactMode ? 'w-6 h-6 sm:w-7 sm:h-7' : 'w-9 h-9 sm:w-10 sm:h-10'
463
+ }`}
464
+ style={{
465
+ borderColor: provider.color,
466
+ boxShadow: `0 0 16px ${provider.color}30`,
467
+ }}
468
+ />
469
+ ) : (
470
+ <div
471
+ className={`shrink-0 rounded-lg sm:rounded-xl flex items-center justify-center font-medium border-2 ${
472
+ compactMode ? 'w-6 h-6 sm:w-7 sm:h-7 text-xs sm:text-sm' : 'w-9 h-9 sm:w-10 sm:h-10 text-base sm:text-lg'
473
+ }`}
474
+ style={{
475
+ backgroundColor: `${provider.color}15`,
476
+ borderColor: provider.color,
477
+ color: provider.color,
478
+ boxShadow: `0 0 16px ${provider.color}30`,
479
+ }}
480
+ >
481
+ {provider.icon}
482
+ </div>
483
+ )}
484
+
485
+ <div className="flex-1 min-w-0 overflow-hidden">
486
+ {/* Message Header */}
487
+ <div className={`flex items-center gap-2 flex-wrap ${compactMode ? 'mb-0.5' : 'mb-1.5'}`}>
488
+ <MessageSenderName
489
+ displayName={displayName}
490
+ color={provider.color}
491
+ isCurrentUser={isFromCurrentUser}
492
+ agent={senderAgent}
493
+ userPresence={senderUser}
494
+ onAgentClick={onAgentClick}
495
+ onUserClick={onUserClick}
496
+ />
497
+
498
+ {message.to !== '*' && (
499
+ <>
500
+ <span className="text-text-dim text-xs">→</span>
501
+ <MessageSenderName
502
+ displayName={message.to}
503
+ color={recipientProviderConfig?.color || '#00d9ff'}
504
+ agent={recipientAgent}
505
+ userPresence={recipientUser}
506
+ onAgentClick={onAgentClick}
507
+ onUserClick={onUserClick}
508
+ />
509
+ </>
510
+ )}
511
+
512
+ {message.thread && (
513
+ <span className="text-xs py-0.5 px-2 rounded-full font-mono font-medium bg-accent-purple/20 text-accent-purple">
514
+ {message.thread}
515
+ </span>
516
+ )}
517
+
518
+ {message.to === '*' && (
519
+ <span className="text-xs py-0.5 px-2 rounded-full uppercase font-medium bg-warning/20 text-warning">
520
+ broadcast
521
+ </span>
522
+ )}
523
+
524
+ {showTimestamps && (
525
+ <span className="text-text-dim text-xs ml-auto font-mono">{timestamp}</span>
526
+ )}
527
+
528
+ {/* Message status indicator - show for messages sent by current user */}
529
+ {isFromCurrentUser && (
530
+ <MessageStatusIndicator status={message.status} size="small" />
531
+ )}
532
+
533
+ {/* Thinking indicator - show when recipient is processing */}
534
+ {showThinking && (
535
+ <ThinkingIndicator
536
+ isProcessing={true}
537
+ processingStartedAt={recipientProcessing?.processingStartedAt}
538
+ size="small"
539
+ showLabel={true}
540
+ />
541
+ )}
542
+
543
+ {/* Thread/Reply button */}
544
+ <button
545
+ className={`
546
+ inline-flex items-center gap-1.5 p-1.5 rounded-lg transition-all duration-150 cursor-pointer border-none
547
+ ${hasReplies || message.thread
548
+ ? 'text-accent-cyan bg-accent-cyan/10 hover:bg-accent-cyan/20'
549
+ : 'text-text-muted bg-transparent opacity-0 group-hover:opacity-100 hover:text-accent-cyan hover:bg-accent-cyan/10'}
550
+ `}
551
+ onClick={() => onThreadClick?.(message.thread || message.id)}
552
+ title={message.thread ? `View thread: ${message.thread}` : (hasReplies ? `${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` : 'Reply in thread')}
553
+ >
554
+ <ThreadIcon />
555
+ {hasReplies && (
556
+ <span className="text-xs font-medium">{replyCount}</span>
557
+ )}
558
+ </button>
559
+ </div>
560
+
561
+ {/* Message Content */}
562
+ <div className="text-sm leading-relaxed text-text-primary whitespace-pre-wrap break-words">
563
+ {formatMessageBody(message.content)}
564
+ </div>
565
+
566
+ {/* Attachments */}
567
+ {message.attachments && message.attachments.length > 0 && (
568
+ <MessageAttachments attachments={message.attachments} />
569
+ )}
570
+
571
+ {/* Reactions */}
572
+ {onReaction && (
573
+ <ReactionChips
574
+ reactions={message.reactions || []}
575
+ messageId={message.id}
576
+ currentUser={currentUser?.displayName || 'user'}
577
+ onToggleReaction={onReaction}
578
+ />
579
+ )}
580
+
581
+ {/* Live log preview while the recipient agent is processing */}
582
+ {showThinking && recipientAgent && (
583
+ <AgentLogPreview
584
+ agentName={recipientAgent.name}
585
+ lines={5}
586
+ compact={compactMode}
587
+ onExpand={onLogsClick ? () => onLogsClick(recipientAgent) : undefined}
588
+ />
589
+ )}
590
+ </div>
591
+ </div>
592
+ );
593
+ }
594
+
595
+ /**
596
+ * Message Attachments Component
597
+ * Displays image attachments with lightbox functionality
598
+ */
599
+ interface MessageAttachmentsProps {
600
+ attachments: Attachment[];
601
+ }
602
+
603
+ function MessageAttachments({ attachments }: MessageAttachmentsProps) {
604
+ const [lightboxImage, setLightboxImage] = useState<Attachment | null>(null);
605
+
606
+ const imageAttachments = attachments.filter(a =>
607
+ a.mimeType.startsWith('image/')
608
+ );
609
+
610
+ if (imageAttachments.length === 0) return null;
611
+
612
+ return (
613
+ <>
614
+ <div className="flex flex-wrap gap-2 mt-2">
615
+ {imageAttachments.map((attachment) => (
616
+ <button
617
+ key={attachment.id}
618
+ type="button"
619
+ onClick={() => setLightboxImage(attachment)}
620
+ className="relative group cursor-pointer bg-transparent border-0 p-0"
621
+ title={`View ${attachment.filename}`}
622
+ >
623
+ <img
624
+ src={attachment.data || attachment.url}
625
+ alt={attachment.filename}
626
+ className="max-h-48 max-w-xs rounded-lg border border-border-subtle object-cover transition-all duration-150 group-hover:border-accent-cyan/50 group-hover:shadow-[0_0_8px_rgba(0,217,255,0.2)]"
627
+ loading="lazy"
628
+ />
629
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 rounded-lg transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
630
+ <svg
631
+ width="24"
632
+ height="24"
633
+ viewBox="0 0 24 24"
634
+ fill="none"
635
+ stroke="white"
636
+ strokeWidth="2"
637
+ className="drop-shadow-lg"
638
+ >
639
+ <circle cx="11" cy="11" r="8" />
640
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
641
+ <line x1="11" y1="8" x2="11" y2="14" />
642
+ <line x1="8" y1="11" x2="14" y2="11" />
643
+ </svg>
644
+ </div>
645
+ </button>
646
+ ))}
647
+ </div>
648
+
649
+ {/* Lightbox Modal */}
650
+ {lightboxImage && (
651
+ <div
652
+ className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
653
+ onClick={() => setLightboxImage(null)}
654
+ >
655
+ <div className="relative max-w-[90vw] max-h-[90vh]">
656
+ <img
657
+ src={lightboxImage.data || lightboxImage.url}
658
+ alt={lightboxImage.filename}
659
+ className="max-w-full max-h-[90vh] rounded-lg shadow-2xl"
660
+ onClick={(e) => e.stopPropagation()}
661
+ />
662
+ <button
663
+ type="button"
664
+ onClick={() => setLightboxImage(null)}
665
+ className="absolute -top-3 -right-3 w-8 h-8 bg-bg-tertiary border border-border-subtle rounded-full flex items-center justify-center text-text-muted hover:text-text-primary hover:bg-bg-card transition-colors shadow-lg"
666
+ title="Close"
667
+ >
668
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
669
+ <line x1="18" y1="6" x2="6" y2="18" />
670
+ <line x1="6" y1="6" x2="18" y2="18" />
671
+ </svg>
672
+ </button>
673
+ <div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/60 to-transparent rounded-b-lg">
674
+ <p className="text-white text-sm truncate">{lightboxImage.filename}</p>
675
+ </div>
676
+ </div>
677
+ </div>
678
+ )}
679
+ </>
680
+ );
681
+ }
682
+
683
+ /**
684
+ * Format timestamp for display
685
+ */
686
+ function formatTimestamp(timestamp: string | number): string {
687
+ const date = new Date(timestamp);
688
+ const now = new Date();
689
+ const isToday = date.toDateString() === now.toDateString();
690
+
691
+ if (isToday) {
692
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
693
+ }
694
+
695
+ const yesterday = new Date(now);
696
+ yesterday.setDate(yesterday.getDate() - 1);
697
+ const isYesterday = date.toDateString() === yesterday.toDateString();
698
+
699
+ if (isYesterday) {
700
+ return `Yesterday ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
701
+ }
702
+
703
+ return date.toLocaleDateString([], {
704
+ month: 'short',
705
+ day: 'numeric',
706
+ hour: '2-digit',
707
+ minute: '2-digit',
708
+ });
709
+ }
710
+
711
+ function EmptyIcon() {
712
+ return (
713
+ <svg className="mb-4 opacity-50 text-text-muted" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
714
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
715
+ </svg>
716
+ );
717
+ }
718
+
719
+ function ThreadIcon() {
720
+ return (
721
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
722
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
723
+ </svg>
724
+ );
725
+ }