@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,3424 @@
1
+ /**
2
+ * Dashboard V2 - Main Application Component
3
+ *
4
+ * Root component that combines sidebar, header, and main content area.
5
+ * Manages global state via hooks and provides context to child components.
6
+ */
7
+
8
+ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
9
+ import type { Agent, Project, Message, AgentSummary, ActivityEvent, Reaction } from '../types';
10
+ import { ActivityFeed } from './ActivityFeed';
11
+ import { Sidebar } from './layout/Sidebar';
12
+ import { Header } from './layout/Header';
13
+ import { MessageList } from './MessageList';
14
+ import { ThreadPanel } from './ThreadPanel';
15
+ import { CommandPalette, type TaskCreateRequest, PRIORITY_CONFIG } from './CommandPalette';
16
+ import { SpawnModal, type SpawnConfig } from './SpawnModal';
17
+ import { NewConversationModal } from './NewConversationModal';
18
+ import { SettingsPage, defaultSettings, type Settings } from './settings';
19
+ import { ConversationHistory } from './ConversationHistory';
20
+ import type { HumanUser } from './MentionAutocomplete';
21
+ import { NotificationToast, useToasts } from './NotificationToast';
22
+ import { WorkspaceSelector, type Workspace } from './WorkspaceSelector';
23
+ import { AddWorkspaceModal } from './AddWorkspaceModal';
24
+ import { LogViewerPanel } from './LogViewerPanel';
25
+ import { TrajectoryViewer } from './TrajectoryViewer';
26
+ import { DecisionQueue, type Decision } from './DecisionQueue';
27
+ import { FleetOverview } from './FleetOverview';
28
+ import type { ServerInfo } from './ServerCard';
29
+ import { TypingIndicator } from './TypingIndicator';
30
+ import { MessageComposer } from './MessageComposer';
31
+ import { OnlineUsersIndicator } from './OnlineUsersIndicator';
32
+ import { UserProfilePanel } from './UserProfilePanel';
33
+ import { AgentProfilePanel } from './AgentProfilePanel';
34
+ import { useDirectMessage } from './hooks/useDirectMessage';
35
+ import { CoordinatorPanel } from './CoordinatorPanel';
36
+ import { BillingResult } from './BillingResult';
37
+ import { UsageBanner } from './UsageBanner';
38
+ import { useWebSocket, type DashboardData } from './hooks/useWebSocket';
39
+ import { useAgents } from './hooks/useAgents';
40
+ import { useMessages } from './hooks/useMessages';
41
+ import { useThread } from './hooks/useThread';
42
+ import { useOrchestrator } from './hooks/useOrchestrator';
43
+ import { useTrajectory } from './hooks/useTrajectory';
44
+ import { useRecentRepos } from './hooks/useRecentRepos';
45
+ import { useWorkspaceRepos } from './hooks/useWorkspaceRepos';
46
+ import { usePresence, type UserPresence } from './hooks/usePresence';
47
+ import {
48
+ ChannelViewV1,
49
+ SearchInput,
50
+ CreateChannelModal,
51
+ InviteToChannelModal,
52
+ MemberManagementPanel,
53
+ listChannels,
54
+ getMessages,
55
+ getChannelMembers,
56
+ removeMember as removeChannelMember,
57
+ sendMessage as sendChannelApiMessage,
58
+ markRead,
59
+ createChannel,
60
+ type Channel,
61
+ type ChannelMember,
62
+ type ChannelMessage as ChannelApiMessage,
63
+ type UnreadState,
64
+ type CreateChannelRequest,
65
+ } from './channels';
66
+ import { useWorkspaceMembers, filterOnlineUsersByWorkspace } from './hooks/useWorkspaceMembers';
67
+ import { useCloudSessionOptional } from './CloudSessionProvider';
68
+ import { WorkspaceProvider } from './WorkspaceContext';
69
+ import { api, convertApiDecision, setActiveWorkspaceId as setApiWorkspaceId, getActiveWorkspaceId, getCsrfToken } from '../lib/api';
70
+ import { cloudApi } from '../lib/cloudApi';
71
+ import { mergeAgentsForDashboard } from '../lib/agent-merge';
72
+ import { useUrlRouting, parseRoute, type Route } from '../lib/useUrlRouting';
73
+ import type { CurrentUser } from './MessageList';
74
+
75
+ /**
76
+ * Check if a sender is a human user (not an agent or system name)
77
+ * Extracts the logic for identifying human users to avoid duplication
78
+ */
79
+ function isHumanSender(sender: string, agentNames: Set<string>): boolean {
80
+ return sender !== 'Dashboard' &&
81
+ sender !== '*' &&
82
+ !agentNames.has(sender.toLowerCase());
83
+ }
84
+
85
+ const SETTINGS_STORAGE_KEY = 'dashboard-settings';
86
+
87
+ /** Special ID for the Activity feed (broadcasts) */
88
+ export const ACTIVITY_FEED_ID = '__activity__';
89
+
90
+ type LegacyDashboardSettings = {
91
+ theme?: 'dark' | 'light' | 'system';
92
+ compactMode?: boolean;
93
+ showTimestamps?: boolean;
94
+ soundEnabled?: boolean;
95
+ notificationsEnabled?: boolean;
96
+ autoScrollMessages?: boolean;
97
+ };
98
+
99
+ function mergeSettings(base: Settings, partial: Partial<Settings>): Settings {
100
+ return {
101
+ ...base,
102
+ ...partial,
103
+ notifications: { ...base.notifications, ...partial.notifications },
104
+ display: { ...base.display, ...partial.display },
105
+ messages: { ...base.messages, ...partial.messages },
106
+ connection: { ...base.connection, ...partial.connection },
107
+ agentDefaults: {
108
+ ...base.agentDefaults,
109
+ ...partial.agentDefaults,
110
+ defaultModels: {
111
+ ...base.agentDefaults?.defaultModels,
112
+ ...partial.agentDefaults?.defaultModels,
113
+ },
114
+ },
115
+ };
116
+ }
117
+
118
+ function migrateLegacySettings(raw: LegacyDashboardSettings): Settings {
119
+ const theme = raw.theme && ['dark', 'light', 'system'].includes(raw.theme)
120
+ ? raw.theme
121
+ : defaultSettings.theme;
122
+ const sound = raw.soundEnabled ?? defaultSettings.notifications.sound;
123
+ const desktop = raw.notificationsEnabled ?? defaultSettings.notifications.desktop;
124
+ return {
125
+ ...defaultSettings,
126
+ theme,
127
+ display: {
128
+ ...defaultSettings.display,
129
+ compactMode: raw.compactMode ?? defaultSettings.display.compactMode,
130
+ showTimestamps: raw.showTimestamps ?? defaultSettings.display.showTimestamps,
131
+ },
132
+ notifications: {
133
+ ...defaultSettings.notifications,
134
+ sound,
135
+ desktop,
136
+ enabled: sound || desktop || defaultSettings.notifications.mentionsOnly,
137
+ },
138
+ messages: {
139
+ ...defaultSettings.messages,
140
+ autoScroll: raw.autoScrollMessages ?? defaultSettings.messages.autoScroll,
141
+ },
142
+ };
143
+ }
144
+
145
+ function loadSettingsFromStorage(): Settings {
146
+ if (typeof window === 'undefined') return defaultSettings;
147
+ try {
148
+ const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
149
+ if (!saved) return defaultSettings;
150
+ const parsed = JSON.parse(saved);
151
+ if (!parsed || typeof parsed !== 'object') return defaultSettings;
152
+ if ('notifications' in parsed && 'display' in parsed) {
153
+ const merged = mergeSettings(defaultSettings, parsed as Partial<Settings>);
154
+ merged.notifications.enabled = merged.notifications.sound ||
155
+ merged.notifications.desktop ||
156
+ merged.notifications.mentionsOnly;
157
+ return merged;
158
+ }
159
+ if ('notificationsEnabled' in parsed || 'soundEnabled' in parsed || 'autoScrollMessages' in parsed) {
160
+ return migrateLegacySettings(parsed as LegacyDashboardSettings);
161
+ }
162
+ } catch {
163
+ // Fall back to defaults
164
+ }
165
+ return defaultSettings;
166
+ }
167
+
168
+ function saveSettingsToStorage(settings: Settings) {
169
+ if (typeof window === 'undefined') return;
170
+ try {
171
+ localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
172
+ } catch {
173
+ // Ignore localStorage failures
174
+ }
175
+ }
176
+
177
+ function playNotificationSound() {
178
+ if (typeof window === 'undefined') return;
179
+ const AudioContextConstructor =
180
+ window.AudioContext ||
181
+ (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
182
+ if (!AudioContextConstructor) return;
183
+ try {
184
+ const context = new AudioContextConstructor();
185
+ const oscillator = context.createOscillator();
186
+ const gain = context.createGain();
187
+ oscillator.type = 'sine';
188
+ oscillator.frequency.value = 880;
189
+ gain.gain.value = 0.03;
190
+ oscillator.connect(gain);
191
+ gain.connect(context.destination);
192
+ oscillator.start();
193
+ oscillator.stop(context.currentTime + 0.12);
194
+ oscillator.onended = () => {
195
+ context.close().catch(() => undefined);
196
+ };
197
+ } catch {
198
+ // Audio might be blocked by browser autoplay policies
199
+ }
200
+ }
201
+
202
+ export interface AppProps {
203
+ /** Initial WebSocket URL (optional, defaults to current host) */
204
+ wsUrl?: string;
205
+ /** Orchestrator API URL (optional, defaults to localhost:3456) */
206
+ orchestratorUrl?: string;
207
+ /** Enable reaction UI on messages (default: false) */
208
+ enableReactions?: boolean;
209
+ }
210
+
211
+ const REACTION_OVERRIDE_TTL = 5000; // 5s — enough for API round-trip + WS echo
212
+
213
+ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProps) {
214
+ // Ref to hold event handler - needed because handlePresenceEvent is defined later
215
+ // but we need to pass it to useWebSocket which is called first
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ const wsEventHandlerRef = useRef<((event: any) => void) | undefined>(undefined);
218
+
219
+ // WebSocket connection for real-time data (per-project daemon)
220
+ // Pass event handler for direct_message/channel_message events in local mode
221
+ const { data: wsData, isConnected, error: wsError } = useWebSocket({
222
+ url: wsUrl,
223
+ onEvent: (event) => wsEventHandlerRef.current?.(event),
224
+ });
225
+
226
+ // REST fallback: fetch initial data when WebSocket fails
227
+ const [restData, setRestData] = useState<DashboardData | null>(null);
228
+ const [restFallbackFailed, setRestFallbackFailed] = useState(false);
229
+ useEffect(() => {
230
+ if (wsError && !wsData && !restData) {
231
+ let cancelled = false;
232
+ setRestFallbackFailed(false);
233
+ api.getData().then((resp) => {
234
+ if (cancelled) return;
235
+ if (resp.success && resp.data) {
236
+ setRestData(resp.data as DashboardData);
237
+ } else {
238
+ setRestFallbackFailed(true);
239
+ }
240
+ }).catch(() => { if (!cancelled) setRestFallbackFailed(true); });
241
+ return () => { cancelled = true; };
242
+ }
243
+ }, [wsError, wsData, restData]);
244
+
245
+ // Local reaction overrides for optimistic UI updates (TTL-based expiry)
246
+ const [reactionOverrides, setReactionOverrides] = useState<Map<string, { reactions: Reaction[]; timestamp: number }>>(new Map());
247
+
248
+ // Use WebSocket data if available, otherwise fall back to REST data
249
+ // Merge in local reaction overrides
250
+ const rawData = wsData || restData;
251
+ const rawDataRef = useRef(rawData);
252
+ rawDataRef.current = rawData;
253
+
254
+ // Expire stale reaction overrides when WebSocket delivers fresh data
255
+ useEffect(() => {
256
+ if (rawData && reactionOverrides.size > 0) {
257
+ const now = Date.now();
258
+ setReactionOverrides((prev) => {
259
+ const next = new Map<string, { reactions: Reaction[]; timestamp: number }>();
260
+ for (const [id, entry] of prev) {
261
+ if (now - entry.timestamp < REACTION_OVERRIDE_TTL) next.set(id, entry);
262
+ }
263
+ return next.size === prev.size ? prev : next;
264
+ });
265
+ }
266
+ // eslint-disable-next-line react-hooks/exhaustive-deps
267
+ }, [rawData]);
268
+
269
+ const data = useMemo(() => {
270
+ if (!rawData || reactionOverrides.size === 0) return rawData;
271
+ return {
272
+ ...rawData,
273
+ messages: rawData.messages.map((msg) => {
274
+ const entry = reactionOverrides.get(msg.id);
275
+ return entry ? { ...msg, reactions: entry.reactions } : msg;
276
+ }),
277
+ };
278
+ }, [rawData, reactionOverrides]);
279
+
280
+ // Orchestrator for multi-workspace management
281
+ const {
282
+ workspaces,
283
+ activeWorkspaceId,
284
+ agents: orchestratorAgents,
285
+ isConnected: isOrchestratorConnected,
286
+ isLoading: isOrchestratorLoading,
287
+ error: orchestratorError,
288
+ switchWorkspace,
289
+ addWorkspace,
290
+ removeWorkspace,
291
+ spawnAgent: orchestratorSpawnAgent,
292
+ stopAgent: orchestratorStopAgent,
293
+ } = useOrchestrator({ apiUrl: orchestratorUrl });
294
+
295
+ // Cloud session for user info (GitHub avatar/username)
296
+ const cloudSession = useCloudSessionOptional();
297
+
298
+ // Derive current user from cloud session (falls back to undefined in non-cloud mode)
299
+ const currentUser: CurrentUser | undefined = cloudSession?.user
300
+ ? {
301
+ displayName: cloudSession.user.githubUsername || cloudSession.user.displayName || '',
302
+ avatarUrl: cloudSession.user.avatarUrl,
303
+ }
304
+ : undefined;
305
+
306
+ // Cloud workspaces state (for cloud mode)
307
+ // Includes owned, member, and contributor workspaces (via GitHub repo access)
308
+ const [cloudWorkspaces, setCloudWorkspaces] = useState<Array<{
309
+ id: string;
310
+ name: string;
311
+ status: string;
312
+ publicUrl?: string;
313
+ accessType?: 'owner' | 'member' | 'contributor';
314
+ permission?: 'admin' | 'write' | 'read';
315
+ }>>([]);
316
+ // Initialize from API module if already set (e.g., by DashboardPage when connecting to workspace)
317
+ const [activeCloudWorkspaceId, setActiveCloudWorkspaceId] = useState<string | null>(() => getActiveWorkspaceId());
318
+ const [isLoadingCloudWorkspaces, setIsLoadingCloudWorkspaces] = useState(false);
319
+
320
+ // Local agents from linked daemons
321
+ const [localAgents, setLocalAgents] = useState<Agent[]>([]);
322
+
323
+ // Fetch cloud workspaces when in cloud mode
324
+ // Uses getAccessibleWorkspaces to include contributor workspaces (via GitHub repos)
325
+ useEffect(() => {
326
+ if (!cloudSession?.user) return;
327
+
328
+ const fetchCloudWorkspaces = async (isInitialLoad: boolean) => {
329
+ // Only show loading indicator on initial load, not on background refreshes
330
+ if (isInitialLoad) {
331
+ setIsLoadingCloudWorkspaces(true);
332
+ }
333
+ try {
334
+ const result = await cloudApi.getAccessibleWorkspaces();
335
+ if (result.success && result.data.workspaces) {
336
+ setCloudWorkspaces(result.data.workspaces);
337
+ const workspaceIds = new Set(result.data.workspaces.map(w => w.id));
338
+ // Validate current selection exists, or auto-select first workspace
339
+ if (activeCloudWorkspaceId && !workspaceIds.has(activeCloudWorkspaceId)) {
340
+ // Current workspace no longer exists, clear selection to trigger auto-select
341
+ if (result.data.workspaces.length > 0) {
342
+ const firstWorkspaceId = result.data.workspaces[0].id;
343
+ setActiveCloudWorkspaceId(firstWorkspaceId);
344
+ setApiWorkspaceId(firstWorkspaceId);
345
+ } else {
346
+ setActiveCloudWorkspaceId(null);
347
+ setApiWorkspaceId(null);
348
+ }
349
+ } else if (!activeCloudWorkspaceId && result.data.workspaces.length > 0) {
350
+ // No selection yet, auto-select first workspace
351
+ const firstWorkspaceId = result.data.workspaces[0].id;
352
+ setActiveCloudWorkspaceId(firstWorkspaceId);
353
+ // Sync immediately with api module to avoid race conditions
354
+ setApiWorkspaceId(firstWorkspaceId);
355
+ }
356
+ }
357
+ } catch (err) {
358
+ console.error('Failed to fetch cloud workspaces:', err);
359
+ } finally {
360
+ if (isInitialLoad) {
361
+ setIsLoadingCloudWorkspaces(false);
362
+ }
363
+ }
364
+ };
365
+
366
+ // Initial fetch with loading indicator
367
+ fetchCloudWorkspaces(true);
368
+ // Poll for updates every 30 seconds without loading indicator
369
+ const interval = setInterval(() => fetchCloudWorkspaces(false), 30000);
370
+ return () => clearInterval(interval);
371
+ }, [cloudSession?.user, activeCloudWorkspaceId]);
372
+
373
+ // Fetch local agents for the active workspace
374
+ useEffect(() => {
375
+ if (!cloudSession?.user || !activeCloudWorkspaceId) {
376
+ setLocalAgents([]);
377
+ return;
378
+ }
379
+
380
+ const fetchLocalAgents = async () => {
381
+ try {
382
+ const result = await api.get<{
383
+ agents: Array<{
384
+ name: string;
385
+ status: string;
386
+ isLocal: boolean;
387
+ isHuman?: boolean;
388
+ avatarUrl?: string;
389
+ daemonId: string;
390
+ daemonName: string;
391
+ daemonStatus: string;
392
+ machineId: string;
393
+ lastSeenAt: string | null;
394
+ }>;
395
+ }>(`/api/daemons/workspace/${activeCloudWorkspaceId}/agents`);
396
+
397
+ if (result.agents) {
398
+ // Convert API response to Agent format
399
+ // Agent status is 'online' when daemon is online (agent is connected to daemon)
400
+ const agents: Agent[] = result.agents.map((a) => ({
401
+ name: a.name,
402
+ status: a.daemonStatus === 'online' ? 'online' : 'offline',
403
+ // Only mark AI agents as "local" (from linked daemon), not human users
404
+ isLocal: !a.isHuman,
405
+ isHuman: a.isHuman,
406
+ avatarUrl: a.avatarUrl,
407
+ // Don't include daemon info for human users
408
+ daemonName: a.isHuman ? undefined : a.daemonName,
409
+ machineId: a.isHuman ? undefined : a.machineId,
410
+ lastSeen: a.lastSeenAt || undefined,
411
+ }));
412
+ setLocalAgents(agents);
413
+ }
414
+ } catch (err) {
415
+ console.error('Failed to fetch local agents:', err);
416
+ setLocalAgents([]);
417
+ }
418
+ };
419
+
420
+ fetchLocalAgents();
421
+ // Poll for updates every 15 seconds
422
+ const interval = setInterval(fetchLocalAgents, 15000);
423
+ return () => clearInterval(interval);
424
+ }, [cloudSession?.user, activeCloudWorkspaceId]);
425
+
426
+ // Determine which workspaces to use (cloud mode or orchestrator)
427
+ // Use hostname-based detection from CloudSessionProvider (immediate) instead of user presence (async)
428
+ // This prevents fetching with 'default' workspaceId before session loads
429
+ const isCloudMode = cloudSession?.isCloudMode ?? false;
430
+ const effectiveWorkspaces = useMemo(() => {
431
+ if (isCloudMode && cloudWorkspaces.length > 0) {
432
+ // Convert cloud workspaces to the format expected by WorkspaceSelector
433
+ // Includes owned, member, and contributor workspaces
434
+ return cloudWorkspaces.map(ws => ({
435
+ id: ws.id,
436
+ name: ws.name,
437
+ path: ws.publicUrl || `/workspace/${ws.name}`,
438
+ status: ws.status === 'running' ? 'active' as const : 'inactive' as const,
439
+ provider: 'claude' as const,
440
+ lastActiveAt: new Date(),
441
+ }));
442
+ }
443
+ return workspaces;
444
+ }, [isCloudMode, cloudWorkspaces, workspaces]);
445
+
446
+ // In non-cloud mode, provide a fallback workspace ID for local/mock mode
447
+ // This ensures channels API calls work even without an orchestrator workspace
448
+ // In cloud mode, never use 'default' - it's not a valid UUID and will cause DB errors
449
+ const effectiveActiveWorkspaceId = isCloudMode
450
+ ? activeCloudWorkspaceId // null if no workspace selected in cloud mode
451
+ : (activeWorkspaceId ?? 'default');
452
+ const effectiveIsLoading = isCloudMode ? isLoadingCloudWorkspaces : isOrchestratorLoading;
453
+
454
+ // Sync the active workspace ID with the api module for cloud mode proxying
455
+ // This useEffect serves as a safeguard and handles initial load/edge cases
456
+ // The immediate sync in handleEffectiveWorkspaceSelect handles user-initiated changes
457
+ useEffect(() => {
458
+ if (isCloudMode && activeCloudWorkspaceId) {
459
+ setApiWorkspaceId(activeCloudWorkspaceId);
460
+ } else if (isCloudMode && !activeCloudWorkspaceId) {
461
+ // In cloud mode but no workspace selected - clear the proxy
462
+ setApiWorkspaceId(null);
463
+ } else if (!isCloudMode) {
464
+ // Clear the workspace ID when not in cloud mode
465
+ setApiWorkspaceId(null);
466
+ }
467
+ }, [isCloudMode, activeCloudWorkspaceId]);
468
+
469
+ // Handle workspace selection (works for both cloud and orchestrator)
470
+ const handleEffectiveWorkspaceSelect = useCallback(async (workspace: { id: string; name: string }) => {
471
+ if (isCloudMode) {
472
+ setActiveCloudWorkspaceId(workspace.id);
473
+ // Sync immediately with api module to avoid race conditions
474
+ // This ensures spawn/release calls use the correct workspace before the useEffect runs
475
+ setApiWorkspaceId(workspace.id);
476
+ } else {
477
+ await switchWorkspace(workspace.id);
478
+ }
479
+ }, [isCloudMode, switchWorkspace]);
480
+
481
+ // Presence tracking for online users and typing indicators
482
+ // Memoize the user object to prevent reconnection on every render
483
+ const presenceUser = useMemo(() =>
484
+ currentUser
485
+ ? { username: currentUser.displayName, avatarUrl: currentUser.avatarUrl }
486
+ : undefined,
487
+ [currentUser?.displayName, currentUser?.avatarUrl]
488
+ );
489
+
490
+ // Channel state: selectedChannelId must be declared before callbacks that use it
491
+ // Local mode defaults to #general, cloud mode defaults to Activity feed
492
+ const [selectedChannelId, setSelectedChannelId] = useState<string | undefined>(
493
+ isCloudMode ? ACTIVITY_FEED_ID : '#general'
494
+ );
495
+
496
+ // Activity feed state - unified timeline of workspace events
497
+ const [activityEvents, setActivityEvents] = useState<ActivityEvent[]>([]);
498
+
499
+ // Helper to add activity events
500
+ const addActivityEvent = useCallback((event: Omit<ActivityEvent, 'id' | 'timestamp'>) => {
501
+ const newEvent: ActivityEvent = {
502
+ ...event,
503
+ id: `activity-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
504
+ timestamp: new Date().toISOString(),
505
+ };
506
+ setActivityEvents(prev => [newEvent, ...prev].slice(0, 200)); // Keep last 200 events
507
+ }, []);
508
+
509
+ // Member management state
510
+ const [showMemberPanel, setShowMemberPanel] = useState(false);
511
+ const [channelMembers, setChannelMembers] = useState<ChannelMember[]>([]);
512
+
513
+ const isDuplicateMessage = useCallback((existing: ChannelApiMessage[], message: ChannelApiMessage) => {
514
+ return existing.some((m) => {
515
+ if (m.id === message.id) return true;
516
+ if (m.from !== message.from) return false;
517
+ if (m.content !== message.content) return false;
518
+ if (m.threadId !== message.threadId) return false;
519
+ const timeDiff = Math.abs(new Date(m.timestamp).getTime() - new Date(message.timestamp).getTime());
520
+ return timeDiff < 2000;
521
+ });
522
+ }, []);
523
+
524
+ const appendChannelMessage = useCallback((channelId: string, message: ChannelApiMessage, options?: { incrementUnread?: boolean }) => {
525
+ const incrementUnread = options?.incrementUnread ?? true;
526
+
527
+ setChannelMessageMap(prev => {
528
+ const list = prev[channelId] ?? [];
529
+ if (isDuplicateMessage(list, message)) return prev;
530
+ const updated = [...list, message].sort(
531
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
532
+ );
533
+ return { ...prev, [channelId]: updated };
534
+ });
535
+
536
+ if (selectedChannelId === channelId) {
537
+ setChannelMessages(prev => {
538
+ if (isDuplicateMessage(prev, message)) return prev;
539
+ const updated = [...prev, message].sort(
540
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
541
+ );
542
+ return updated;
543
+ });
544
+ setChannelUnreadState(undefined);
545
+ } else if (incrementUnread) {
546
+ setChannelsList(prev => {
547
+ const existing = prev.find(c => c.id === channelId);
548
+ if (existing) {
549
+ return prev.map(c =>
550
+ c.id === channelId
551
+ ? { ...c, unreadCount: (c.unreadCount ?? 0) + 1 }
552
+ : c
553
+ );
554
+ }
555
+
556
+ const newChannel: Channel = {
557
+ id: channelId,
558
+ name: channelId.startsWith('#') ? channelId.slice(1) : channelId,
559
+ visibility: 'public',
560
+ status: 'active',
561
+ createdAt: new Date().toISOString(),
562
+ createdBy: currentUser?.displayName || 'Dashboard',
563
+ memberCount: 1,
564
+ unreadCount: 1,
565
+ hasMentions: false,
566
+ isDm: channelId.startsWith('dm:'),
567
+ };
568
+
569
+ return [...prev, newChannel];
570
+ });
571
+ }
572
+ }, [currentUser?.displayName, selectedChannelId]);
573
+
574
+ const handlePresenceEvent = useCallback((event: any) => {
575
+ // Activity feed: capture presence join/leave events
576
+ if (event?.type === 'presence_join' && event.user) {
577
+ const user = event.user;
578
+ // Skip self
579
+ if (user.username !== currentUser?.displayName) {
580
+ addActivityEvent({
581
+ type: 'user_joined',
582
+ actor: user.username,
583
+ actorAvatarUrl: user.avatarUrl,
584
+ actorType: 'user',
585
+ title: 'came online',
586
+ });
587
+ }
588
+ } else if (event?.type === 'presence_leave' && event.username) {
589
+ // Skip self
590
+ if (event.username !== currentUser?.displayName) {
591
+ addActivityEvent({
592
+ type: 'user_left',
593
+ actor: event.username,
594
+ actorType: 'user',
595
+ title: 'went offline',
596
+ });
597
+ }
598
+ } else if (event?.type === 'agent_spawned' && event.agent) {
599
+ // Agent spawned event from backend
600
+ addActivityEvent({
601
+ type: 'agent_spawned',
602
+ actor: event.agent.name || event.agent,
603
+ actorType: 'agent',
604
+ title: 'was spawned',
605
+ description: event.task,
606
+ metadata: { cli: event.cli, task: event.task, spawnedBy: event.spawnedBy },
607
+ });
608
+ } else if (event?.type === 'agent_released' && event.agent) {
609
+ // Agent released event from backend
610
+ addActivityEvent({
611
+ type: 'agent_released',
612
+ actor: event.agent.name || event.agent,
613
+ actorType: 'agent',
614
+ title: 'was released',
615
+ metadata: { releasedBy: event.releasedBy },
616
+ });
617
+ } else if (event?.type === 'channel_created') {
618
+ // Another user created a channel - add it to the list
619
+ const newChannel = event.channel;
620
+ if (!newChannel || !newChannel.id) return;
621
+
622
+ setChannelsList(prev => {
623
+ // Don't add if already exists
624
+ if (prev.some(c => c.id === newChannel.id)) return prev;
625
+
626
+ const channel: Channel = {
627
+ id: newChannel.id,
628
+ name: newChannel.name || newChannel.id,
629
+ description: newChannel.description,
630
+ visibility: newChannel.visibility || 'public',
631
+ status: newChannel.status || 'active',
632
+ createdAt: newChannel.createdAt || new Date().toISOString(),
633
+ createdBy: newChannel.createdBy || 'unknown',
634
+ memberCount: newChannel.memberCount || 1,
635
+ unreadCount: newChannel.unreadCount || 0,
636
+ hasMentions: newChannel.hasMentions || false,
637
+ isDm: newChannel.isDm || false,
638
+ };
639
+ console.log('[App] Channel created via WebSocket:', channel.id);
640
+ return [...prev, channel];
641
+ });
642
+ } else if (event?.type === 'channel_message') {
643
+ const channelId = event.channel as string | undefined;
644
+ if (!channelId) return;
645
+ const sender = event.from || 'unknown';
646
+ // Use server-provided entity type if available, otherwise derive locally
647
+ const fromEntityType = event.fromEntityType || (currentUser?.displayName && sender === currentUser.displayName ? 'user' : 'agent');
648
+ const msg: ChannelApiMessage = {
649
+ id: event.id ?? `ws-${Date.now()}`,
650
+ channelId,
651
+ from: sender,
652
+ fromEntityType,
653
+ fromAvatarUrl: event.fromAvatarUrl,
654
+ content: event.body ?? '',
655
+ timestamp: event.timestamp || new Date().toISOString(),
656
+ threadId: event.thread,
657
+ isRead: selectedChannelId === channelId,
658
+ };
659
+ appendChannelMessage(channelId, msg, { incrementUnread: selectedChannelId !== channelId });
660
+ } else if (event?.type === 'direct_message') {
661
+ // Handle direct messages sent to the user
662
+ // In local mode without auth, use targetUser from event or fallback to 'Dashboard'
663
+ const sender = event.from || 'unknown';
664
+ const recipient = currentUser?.displayName || event.targetUser || 'Dashboard';
665
+
666
+ // Create DM channel ID with sorted participants for consistency
667
+ const participants = [sender, recipient].sort();
668
+ const dmChannelId = `dm:${participants.join(':')}`;
669
+
670
+ // Use server-provided entity type if available
671
+ const fromEntityType = event.fromEntityType || 'agent';
672
+ const msg: ChannelApiMessage = {
673
+ id: event.id ?? `dm-${Date.now()}`,
674
+ channelId: dmChannelId,
675
+ from: sender,
676
+ fromEntityType,
677
+ fromAvatarUrl: event.fromAvatarUrl,
678
+ content: event.body ?? '',
679
+ timestamp: event.timestamp || new Date().toISOString(),
680
+ threadId: event.thread,
681
+ isRead: selectedChannelId === dmChannelId,
682
+ };
683
+ appendChannelMessage(dmChannelId, msg, { incrementUnread: selectedChannelId !== dmChannelId });
684
+ }
685
+ }, [addActivityEvent, appendChannelMessage, currentUser?.displayName, selectedChannelId]);
686
+
687
+ // Keep the ref in sync with the callback for useWebSocket's onEvent
688
+ // This enables direct_message handling in local mode (where usePresence may not connect)
689
+ wsEventHandlerRef.current = handlePresenceEvent;
690
+
691
+ const { onlineUsers: allOnlineUsers, typingUsers, sendTyping, isConnected: isPresenceConnected } = usePresence({
692
+ currentUser: presenceUser,
693
+ onEvent: handlePresenceEvent,
694
+ workspaceId: effectiveActiveWorkspaceId ?? undefined,
695
+ });
696
+
697
+ // Keep local username for channel API calls
698
+ // Clear stale username from previous cloud/mock sessions when in local mode
699
+ useEffect(() => {
700
+ if (typeof window !== 'undefined') {
701
+ if (currentUser?.displayName) {
702
+ localStorage.setItem('relay_username', currentUser.displayName);
703
+ } else if (!isCloudMode) {
704
+ localStorage.removeItem('relay_username');
705
+ }
706
+ }
707
+ }, [currentUser?.displayName, isCloudMode]);
708
+
709
+ // Filter online users by workspace membership (cloud mode only)
710
+ const { memberUsernames } = useWorkspaceMembers({
711
+ workspaceId: effectiveActiveWorkspaceId ?? undefined,
712
+ enabled: isCloudMode && !!effectiveActiveWorkspaceId,
713
+ });
714
+
715
+ // Filter online users to only show those with access to current workspace
716
+ const onlineUsers = useMemo(
717
+ () => filterOnlineUsersByWorkspace(allOnlineUsers, memberUsernames),
718
+ [allOnlineUsers, memberUsernames]
719
+ );
720
+
721
+ // User profile panel state
722
+ const [selectedUserProfile, setSelectedUserProfile] = useState<UserPresence | null>(null);
723
+ const [pendingMention, setPendingMention] = useState<string | undefined>();
724
+
725
+ // Agent profile panel state
726
+ const [selectedAgentProfile, setSelectedAgentProfile] = useState<Agent | null>(null);
727
+
728
+ // Agent summaries lookup
729
+ const agentSummariesMap = useMemo(() => {
730
+ const map = new Map<string, AgentSummary>();
731
+ for (const summary of data?.summaries ?? []) {
732
+ map.set(summary.agentName.toLowerCase(), summary);
733
+ }
734
+ return map;
735
+ }, [data?.summaries]);
736
+
737
+ // View mode state: 'local' (agents), 'fleet' (multi-server), 'channels' (channel messaging)
738
+ // Local mode defaults to channels view (showing #general), cloud mode defaults to local
739
+ const [viewMode, setViewMode] = useState<'local' | 'fleet' | 'channels'>(
740
+ isCloudMode ? 'local' : 'channels'
741
+ );
742
+
743
+ // Channel state for V1 channels UI
744
+ const [channelsList, setChannelsList] = useState<Channel[]>([]);
745
+ const [archivedChannelsList, setArchivedChannelsList] = useState<Channel[]>([]);
746
+ const [channelMessages, setChannelMessages] = useState<ChannelApiMessage[]>([]);
747
+ const [channelMessageMap, setChannelMessageMap] = useState<Record<string, ChannelApiMessage[]>>({});
748
+ const fetchedChannelsRef = useRef<Set<string>>(new Set()); // Track channels already fetched to prevent loops
749
+ const [isChannelsLoading, setIsChannelsLoading] = useState(false);
750
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
751
+ const [channelUnreadState, setChannelUnreadState] = useState<UnreadState | undefined>();
752
+
753
+ // Default channel IDs that should always be visible
754
+ const DEFAULT_CHANNEL_IDS = ['#general', '#engineering'];
755
+
756
+ const setChannelListsFromResponse = useCallback((response: { channels: Channel[]; archivedChannels?: Channel[] }) => {
757
+ const archived = [
758
+ ...(response.archivedChannels || []),
759
+ ...response.channels.filter(c => c.status === 'archived'),
760
+ ];
761
+ const apiActive = response.channels.filter(c => c.status !== 'archived');
762
+
763
+ // Merge with default channels to ensure #general is always visible
764
+ // Default channels are added if not present in API response
765
+ const apiChannelIds = new Set(apiActive.map(c => c.id));
766
+ const defaultChannelsToAdd: Channel[] = DEFAULT_CHANNEL_IDS
767
+ .filter(id => !apiChannelIds.has(id))
768
+ .map(id => ({
769
+ id,
770
+ name: id.replace('#', ''),
771
+ description: id === '#general' ? 'General discussion for all agents' : 'Engineering discussion',
772
+ visibility: 'public' as const,
773
+ memberCount: 0,
774
+ unreadCount: 0,
775
+ hasMentions: false,
776
+ createdAt: new Date().toISOString(),
777
+ status: 'active' as const,
778
+ createdBy: 'system',
779
+ isDm: false,
780
+ }));
781
+
782
+ setChannelsList([...defaultChannelsToAdd, ...apiActive]);
783
+ setArchivedChannelsList(archived);
784
+ }, []);
785
+
786
+ // Find selected channel object
787
+ const selectedChannel = useMemo(() => {
788
+ if (!selectedChannelId) return undefined;
789
+ return channelsList.find(c => c.id === selectedChannelId) ||
790
+ archivedChannelsList.find(c => c.id === selectedChannelId);
791
+ }, [selectedChannelId, channelsList, archivedChannelsList]);
792
+
793
+ // Project state for unified navigation (converted from workspaces)
794
+ const [projects, setProjects] = useState<Project[]>([]);
795
+ const [currentProject, setCurrentProject] = useState<string | undefined>();
796
+
797
+ // Spawn modal state
798
+ const [isSpawnModalOpen, setIsSpawnModalOpen] = useState(false);
799
+ const [isSpawning, setIsSpawning] = useState(false);
800
+ const [spawnError, setSpawnError] = useState<string | null>(null);
801
+
802
+ // Add workspace modal state
803
+ const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = useState(false);
804
+ const [isAddingWorkspace, setIsAddingWorkspace] = useState(false);
805
+ const [addWorkspaceError, setAddWorkspaceError] = useState<string | null>(null);
806
+
807
+ // Create channel modal state
808
+ const [isCreateChannelOpen, setIsCreateChannelOpen] = useState(false);
809
+ const [isCreatingChannel, setIsCreatingChannel] = useState(false);
810
+
811
+ // Invite to channel modal state
812
+ const [isInviteChannelOpen, setIsInviteChannelOpen] = useState(false);
813
+ const [inviteChannelTarget, setInviteChannelTarget] = useState<Channel | null>(null);
814
+ const [isInvitingToChannel, setIsInvitingToChannel] = useState(false);
815
+
816
+ // Command palette state
817
+ const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
818
+
819
+ // Settings state (theme, display, notifications)
820
+ const [settings, setSettings] = useState<Settings>(() => loadSettingsFromStorage());
821
+ const updateSettings = useCallback((updater: (prev: Settings) => Settings) => {
822
+ setSettings((prev) => updater(prev));
823
+ }, []);
824
+
825
+ // Full settings page state
826
+ const [isFullSettingsOpen, setIsFullSettingsOpen] = useState(false);
827
+ const [settingsInitialTab, setSettingsInitialTab] = useState<'dashboard' | 'workspace' | 'team' | 'billing'>('dashboard');
828
+
829
+ // Conversation history panel state
830
+ const [isHistoryOpen, setIsHistoryOpen] = useState(false);
831
+
832
+ // New conversation modal state
833
+ const [isNewConversationOpen, setIsNewConversationOpen] = useState(false);
834
+
835
+ // DM participant selections (human -> invited agents) and removals
836
+ const [dmSelectedAgentsByHuman, setDmSelectedAgentsByHuman] = useState<Record<string, string[]>>({});
837
+ const [dmRemovedAgentsByHuman, setDmRemovedAgentsByHuman] = useState<Record<string, string[]>>({});
838
+
839
+ // Log viewer panel state
840
+ const [logViewerAgent, setLogViewerAgent] = useState<Agent | null>(null);
841
+
842
+ // Trajectory panel state
843
+ const [isTrajectoryOpen, setIsTrajectoryOpen] = useState(false);
844
+ const {
845
+ steps: trajectorySteps,
846
+ status: trajectoryStatus,
847
+ history: trajectoryHistory,
848
+ isLoading: isTrajectoryLoading,
849
+ selectTrajectory,
850
+ selectedTrajectoryId,
851
+ } = useTrajectory({
852
+ autoPoll: isTrajectoryOpen, // Only poll when panel is open
853
+ });
854
+
855
+ // Get the title of the selected trajectory from history
856
+ const selectedTrajectoryTitle = useMemo(() => {
857
+ if (!selectedTrajectoryId) return null;
858
+ return trajectoryHistory.find(t => t.id === selectedTrajectoryId)?.title ?? null;
859
+ }, [selectedTrajectoryId, trajectoryHistory]);
860
+
861
+ // Recent repos tracking
862
+ const { recentRepos, addRecentRepo, getRecentProjects } = useRecentRepos();
863
+
864
+ // Workspace repos for multi-repo workspaces
865
+ const { repos: workspaceRepos, refetch: refetchWorkspaceRepos } = useWorkspaceRepos({
866
+ workspaceId: effectiveActiveWorkspaceId ?? undefined,
867
+ apiBaseUrl: '/api',
868
+ enabled: isCloudMode && !!effectiveActiveWorkspaceId,
869
+ });
870
+
871
+ // Reset channel state when switching workspaces
872
+ useEffect(() => {
873
+ setChannelMessageMap({});
874
+ setChannelMessages([]);
875
+ setSelectedChannelId(undefined);
876
+ fetchedChannelsRef.current.clear(); // Clear fetch tracking to allow re-fetching with new workspace
877
+ }, [effectiveActiveWorkspaceId]);
878
+
879
+ // Coordinator panel state
880
+ const [isCoordinatorOpen, setIsCoordinatorOpen] = useState(false);
881
+
882
+ // Decision queue state
883
+ const [isDecisionQueueOpen, setIsDecisionQueueOpen] = useState(false);
884
+ const [decisions, setDecisions] = useState<Decision[]>([]);
885
+ const [decisionProcessing, setDecisionProcessing] = useState<Record<string, boolean>>({});
886
+
887
+ // Fleet overview state
888
+ const [isFleetViewActive, setIsFleetViewActive] = useState(false);
889
+ const [fleetServers, setFleetServers] = useState<ServerInfo[]>([]);
890
+
891
+ // Auth revocation notification state
892
+ const { toasts, addToast, dismissToast } = useToasts();
893
+ const [authRevokedAgents, setAuthRevokedAgents] = useState<Set<string>>(new Set());
894
+ const [selectedServerId, setSelectedServerId] = useState<string | undefined>();
895
+
896
+ // Task creation state (tasks are stored in beads, not local state)
897
+ const [isCreatingTask, setIsCreatingTask] = useState(false);
898
+
899
+ // Mobile sidebar state
900
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
901
+
902
+ // Unread message notification state for mobile
903
+ const [hasUnreadMessages, setHasUnreadMessages] = useState(false);
904
+ const lastSeenMessageCountRef = useRef<number>(0);
905
+ const sidebarClosedRef = useRef<boolean>(true); // Track if sidebar is currently closed
906
+ const [dmSeenAt, setDmSeenAt] = useState<Map<string, number>>(new Map());
907
+ const lastNotifiedMessageIdRef = useRef<string | null>(null);
908
+
909
+ // Close sidebar when selecting an agent or project on mobile
910
+ const closeSidebarOnMobile = useCallback(() => {
911
+ if (window.innerWidth <= 768) {
912
+ setIsSidebarOpen(false);
913
+ }
914
+ }, []);
915
+
916
+ // Merge AI agents, human users, and local agents from linked daemons
917
+ const combinedAgents = useMemo(() => {
918
+ return mergeAgentsForDashboard({
919
+ agents: data?.agents,
920
+ users: data?.users,
921
+ localAgents,
922
+ });
923
+ }, [data?.agents, data?.users, localAgents]);
924
+
925
+ // Track previous agents to detect spawns/releases for activity feed
926
+ const prevAgentsRef = useRef<Map<string, Agent>>(new Map());
927
+
928
+ // Detect agent changes and generate activity events
929
+ useEffect(() => {
930
+ if (!combinedAgents || combinedAgents.length === 0) return;
931
+
932
+ const currentAgentMap = new Map(combinedAgents.map(a => [a.name, a]));
933
+ const prevAgentMap = prevAgentsRef.current;
934
+
935
+ // Skip on first load (no previous state to compare)
936
+ if (prevAgentMap.size > 0) {
937
+ // Detect new agents (spawned)
938
+ for (const [name, agent] of currentAgentMap) {
939
+ if (!prevAgentMap.has(name)) {
940
+ addActivityEvent({
941
+ type: 'agent_spawned',
942
+ actor: name,
943
+ actorType: 'agent',
944
+ title: 'came online',
945
+ description: agent.currentTask,
946
+ metadata: { cli: agent.cli, task: agent.currentTask },
947
+ });
948
+ } else {
949
+ // Detect status changes (online/offline)
950
+ const prevAgent = prevAgentMap.get(name)!;
951
+ if (prevAgent.status !== agent.status) {
952
+ if (agent.status === 'online' || agent.status === 'busy') {
953
+ addActivityEvent({
954
+ type: 'agent_online',
955
+ actor: name,
956
+ actorType: 'agent',
957
+ title: 'came online',
958
+ metadata: { cli: agent.cli },
959
+ });
960
+ } else if (agent.status === 'offline') {
961
+ addActivityEvent({
962
+ type: 'agent_offline',
963
+ actor: name,
964
+ actorType: 'agent',
965
+ title: 'went offline',
966
+ });
967
+ }
968
+ }
969
+ }
970
+ }
971
+
972
+ // Detect removed agents (released)
973
+ for (const [name] of prevAgentMap) {
974
+ if (!currentAgentMap.has(name)) {
975
+ addActivityEvent({
976
+ type: 'agent_released',
977
+ actor: name,
978
+ actorType: 'agent',
979
+ title: 'went offline',
980
+ });
981
+ }
982
+ }
983
+ }
984
+
985
+ // Update ref with current agents
986
+ prevAgentsRef.current = currentAgentMap;
987
+ }, [combinedAgents, addActivityEvent]);
988
+
989
+ // Mark a DM conversation as seen (used for unread badges)
990
+ const markDmSeen = useCallback((username: string) => {
991
+ setDmSeenAt((prev) => {
992
+ const next = new Map(prev);
993
+ next.set(username.toLowerCase(), Date.now());
994
+ return next;
995
+ });
996
+ }, []);
997
+
998
+ // Agent state management
999
+ const {
1000
+ agents,
1001
+ groups,
1002
+ selectedAgent,
1003
+ selectAgent,
1004
+ searchQuery,
1005
+ setSearchQuery,
1006
+ totalCount,
1007
+ onlineCount,
1008
+ needsAttentionCount,
1009
+ } = useAgents({
1010
+ agents: combinedAgents,
1011
+ });
1012
+
1013
+ // Message state management
1014
+ const {
1015
+ messages,
1016
+ threadMessages,
1017
+ currentChannel,
1018
+ setCurrentChannel,
1019
+ currentThread,
1020
+ setCurrentThread,
1021
+ activeThreads,
1022
+ totalUnreadThreadCount,
1023
+ sendMessage,
1024
+ isSending,
1025
+ sendError,
1026
+ } = useMessages({
1027
+ messages: data?.messages ?? [],
1028
+ senderName: currentUser?.displayName,
1029
+ });
1030
+
1031
+ // Thread data (API-backed with client-side fallback)
1032
+ // Skip API calls for channel-view threads (useThread doesn't handle ChannelApiMessage)
1033
+ const thread = useThread({
1034
+ threadId: viewMode === 'channels' ? null : currentThread,
1035
+ fallbackMessages: messages,
1036
+ });
1037
+
1038
+ // Human context (DM inline view)
1039
+ const currentHuman = useMemo(() => {
1040
+ if (!currentChannel) return null;
1041
+ return combinedAgents.find(
1042
+ (a) => a.isHuman && a.name.toLowerCase() === currentChannel.toLowerCase()
1043
+ ) || null;
1044
+ }, [combinedAgents, currentChannel]);
1045
+
1046
+ const selectedDmAgents = useMemo(
1047
+ () => (currentHuman ? dmSelectedAgentsByHuman[currentHuman.name] ?? [] : []),
1048
+ [currentHuman, dmSelectedAgentsByHuman]
1049
+ );
1050
+ const removedDmAgents = useMemo(
1051
+ () => (currentHuman ? dmRemovedAgentsByHuman[currentHuman.name] ?? [] : []),
1052
+ [currentHuman, dmRemovedAgentsByHuman]
1053
+ );
1054
+
1055
+ // Use DM hook for message filtering and deduplication
1056
+ const { visibleMessages: dedupedVisibleMessages, participantAgents: dmParticipantAgents } = useDirectMessage({
1057
+ currentHuman,
1058
+ currentUserName: currentUser?.displayName ?? null,
1059
+ messages,
1060
+ agents,
1061
+ selectedDmAgents,
1062
+ removedDmAgents,
1063
+ });
1064
+
1065
+ // For local mode: convert relay messages to channel message format
1066
+ // Filter messages by channel (checking multiple fields for compatibility)
1067
+ const localChannelMessages = useMemo((): ChannelApiMessage[] => {
1068
+ if (effectiveActiveWorkspaceId || !selectedChannelId) return [];
1069
+
1070
+ // Filter messages that belong to this channel
1071
+ const filtered = messages.filter(m => {
1072
+ // Activity feed shows activity events, not messages
1073
+ // Broadcasts go to individual DMs, not shown in any channel
1074
+ if (selectedChannelId === ACTIVITY_FEED_ID) {
1075
+ return false;
1076
+ }
1077
+ // Check if message is explicitly for this channel (CHANNEL_MESSAGE format)
1078
+ if (m.to === selectedChannelId) return true;
1079
+ // Check channel property for channel messages
1080
+ if (m.channel === selectedChannelId) return true;
1081
+ // Legacy: messages with this channel as thread
1082
+ if (m.thread === selectedChannelId) return true;
1083
+ return false;
1084
+ });
1085
+
1086
+ // Convert to ChannelMessage format
1087
+ return filtered.map(m => ({
1088
+ id: m.id,
1089
+ channelId: selectedChannelId,
1090
+ from: m.from,
1091
+ fromEntityType: (m.from === 'Dashboard' || m.from === currentUser?.displayName) ? 'user' : 'agent' as const,
1092
+ content: m.content,
1093
+ timestamp: m.timestamp,
1094
+ isRead: m.isRead ?? true,
1095
+ threadId: m.thread !== selectedChannelId ? m.thread : undefined,
1096
+ }));
1097
+ }, [messages, selectedChannelId, effectiveActiveWorkspaceId, currentUser?.displayName]);
1098
+
1099
+ // Use API-fetched channel messages when available, fall back to WebSocket-derived local messages.
1100
+ // In local mode, the server filters channel messages (with _isChannelMessage flag) out of the
1101
+ // main WebSocket data payload, so localChannelMessages will be empty for #channel messages.
1102
+ // The API fetch (GET /api/channels/:channel/messages) correctly returns them.
1103
+ const effectiveChannelMessages = channelMessages.length > 0 ? channelMessages : localChannelMessages;
1104
+
1105
+ // Extract human users from messages (users who are not agents)
1106
+ // This enables @ mentioning other human users in cloud mode
1107
+ const humanUsers = useMemo((): HumanUser[] => {
1108
+ const agentNames = new Set(agents.map((a) => a.name.toLowerCase()));
1109
+ const seenUsers = new Map<string, HumanUser>();
1110
+
1111
+ // Include current user if in cloud mode
1112
+ if (currentUser) {
1113
+ seenUsers.set(currentUser.displayName.toLowerCase(), {
1114
+ username: currentUser.displayName,
1115
+ avatarUrl: currentUser.avatarUrl,
1116
+ });
1117
+ }
1118
+
1119
+ // Extract unique human users from message senders
1120
+ for (const msg of data?.messages ?? []) {
1121
+ const sender = msg.from;
1122
+ if (sender && isHumanSender(sender, agentNames) && !seenUsers.has(sender.toLowerCase())) {
1123
+ seenUsers.set(sender.toLowerCase(), {
1124
+ username: sender,
1125
+ // Note: We don't have avatar URLs for users from messages
1126
+ // unless we fetch them separately
1127
+ });
1128
+ }
1129
+ }
1130
+
1131
+ return Array.from(seenUsers.values());
1132
+ }, [data?.messages, agents, currentUser]);
1133
+
1134
+ // Unread counts for human conversations (DMs)
1135
+ const humanUnreadCounts = useMemo(() => {
1136
+ if (!currentUser) return {};
1137
+
1138
+ const counts: Record<string, number> = {};
1139
+ const humanNameSet = new Set(
1140
+ combinedAgents.filter((a) => a.isHuman).map((a) => a.name.toLowerCase())
1141
+ );
1142
+
1143
+ for (const msg of data?.messages ?? []) {
1144
+ const sender = msg.from;
1145
+ const recipient = msg.to;
1146
+ if (!sender || !recipient) continue;
1147
+
1148
+ const isToCurrentUser = recipient === currentUser.displayName;
1149
+ const senderIsHuman = humanNameSet.has(sender.toLowerCase());
1150
+ if (!isToCurrentUser || !senderIsHuman) continue;
1151
+
1152
+ const seenAt = dmSeenAt.get(sender.toLowerCase()) ?? 0;
1153
+ const ts = new Date(msg.timestamp).getTime();
1154
+ if (ts > seenAt) {
1155
+ counts[sender] = (counts[sender] || 0) + 1;
1156
+ }
1157
+ }
1158
+
1159
+ return counts;
1160
+ }, [combinedAgents, currentUser, data?.messages, dmSeenAt]);
1161
+
1162
+ // URL routing - handle route changes from browser navigation
1163
+ const handleRouteChange = useCallback((route: Route) => {
1164
+ switch (route.type) {
1165
+ case 'channel':
1166
+ if (route.id) {
1167
+ setViewMode('channels');
1168
+ setSelectedChannelId(route.id === 'activity' ? ACTIVITY_FEED_ID : route.id);
1169
+ }
1170
+ break;
1171
+ case 'dm':
1172
+ case 'agent':
1173
+ if (route.id) {
1174
+ setViewMode('local');
1175
+ setSelectedChannelId(undefined);
1176
+ setCurrentChannel(route.id);
1177
+ }
1178
+ break;
1179
+ case 'settings':
1180
+ setSettingsInitialTab(route.tab || 'dashboard');
1181
+ setIsFullSettingsOpen(true);
1182
+ break;
1183
+ case 'activity':
1184
+ setViewMode('channels');
1185
+ setSelectedChannelId(ACTIVITY_FEED_ID);
1186
+ break;
1187
+ }
1188
+ }, [setCurrentChannel]);
1189
+
1190
+ const {
1191
+ navigateToChannel,
1192
+ navigateToDm,
1193
+ navigateToAgent,
1194
+ navigateToSettings,
1195
+ navigateToActivity,
1196
+ closeSettings: urlCloseSettings,
1197
+ } = useUrlRouting({ onRouteChange: handleRouteChange });
1198
+
1199
+ // Mark DM as seen when actively viewing a human channel
1200
+ useEffect(() => {
1201
+ if (!currentUser || !currentChannel) return;
1202
+ const humanNameSet = new Set(
1203
+ combinedAgents.filter((a) => a.isHuman).map((a) => a.name.toLowerCase())
1204
+ );
1205
+ if (humanNameSet.has(currentChannel.toLowerCase())) {
1206
+ markDmSeen(currentChannel);
1207
+ }
1208
+ }, [combinedAgents, currentChannel, currentUser, markDmSeen]);
1209
+
1210
+ // Track unread messages when sidebar is closed on mobile
1211
+ useEffect(() => {
1212
+ // Only track on mobile viewport
1213
+ const isMobile = window.innerWidth <= 768;
1214
+ if (!isMobile) {
1215
+ setHasUnreadMessages(false);
1216
+ return;
1217
+ }
1218
+
1219
+ const messageCount = messages.length;
1220
+
1221
+ // If sidebar is closed and we have new messages since last seen
1222
+ if (!isSidebarOpen && messageCount > lastSeenMessageCountRef.current) {
1223
+ setHasUnreadMessages(true);
1224
+ }
1225
+
1226
+ // Update the ref based on current sidebar state
1227
+ sidebarClosedRef.current = !isSidebarOpen;
1228
+ }, [messages.length, isSidebarOpen]);
1229
+
1230
+ // Clear unread state and update last seen count when sidebar opens
1231
+ useEffect(() => {
1232
+ if (isSidebarOpen) {
1233
+ setHasUnreadMessages(false);
1234
+ lastSeenMessageCountRef.current = messages.length;
1235
+ }
1236
+ }, [isSidebarOpen, messages.length]);
1237
+
1238
+ // Initialize last seen message count on mount
1239
+ useEffect(() => {
1240
+ lastSeenMessageCountRef.current = messages.length;
1241
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1242
+ }, []);
1243
+
1244
+ // Detect auth revocation messages and show notification
1245
+ useEffect(() => {
1246
+ if (!data?.messages) return;
1247
+
1248
+ for (const msg of data.messages) {
1249
+ // Check for auth_revoked control messages
1250
+ if (msg.content?.includes('auth_revoked') || msg.content?.includes('authentication_error')) {
1251
+ try {
1252
+ const parsed = JSON.parse(msg.content);
1253
+ if (parsed.type === 'auth_revoked' && parsed.agent) {
1254
+ const agentName = parsed.agent;
1255
+ if (!authRevokedAgents.has(agentName)) {
1256
+ setAuthRevokedAgents(prev => new Set([...prev, agentName]));
1257
+ addToast({
1258
+ type: 'error',
1259
+ title: 'Authentication Expired',
1260
+ message: `${agentName}'s API credentials have expired. Please reconnect.`,
1261
+ agentName,
1262
+ duration: 0, // Don't auto-dismiss
1263
+ action: {
1264
+ label: 'Reconnect',
1265
+ onClick: () => {
1266
+ window.location.href = '/providers';
1267
+ },
1268
+ },
1269
+ });
1270
+ }
1271
+ }
1272
+ } catch {
1273
+ // Not JSON, check for plain text auth error patterns
1274
+ if (msg.content?.includes('OAuth token') && msg.content?.includes('expired')) {
1275
+ const agentName = msg.from;
1276
+ if (agentName && !authRevokedAgents.has(agentName)) {
1277
+ setAuthRevokedAgents(prev => new Set([...prev, agentName]));
1278
+ addToast({
1279
+ type: 'error',
1280
+ title: 'Authentication Expired',
1281
+ message: `${agentName}'s API credentials have expired. Please reconnect.`,
1282
+ agentName,
1283
+ duration: 0,
1284
+ action: {
1285
+ label: 'Reconnect',
1286
+ onClick: () => {
1287
+ window.location.href = '/providers';
1288
+ },
1289
+ },
1290
+ });
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ }, [data?.messages, authRevokedAgents, addToast]);
1297
+
1298
+ // Check if fleet view is available
1299
+ const isFleetAvailable = Boolean(data?.fleet?.servers?.length) || workspaces.length > 0;
1300
+
1301
+ // Convert workspaces/repos to projects for unified navigation
1302
+ // In cloud mode, useOrchestrator is disabled so `workspaces` is empty.
1303
+ // Use effectiveWorkspaces which unifies orchestrator and cloud workspaces.
1304
+ const hasWorkspaces = effectiveWorkspaces.length > 0;
1305
+ useEffect(() => {
1306
+ if (hasWorkspaces) {
1307
+ // If we have repos for the active workspace, show each repo as a project folder
1308
+ if (workspaceRepos.length > 1 && effectiveActiveWorkspaceId) {
1309
+ // Create empty project shells from workspace repos.
1310
+ // Agent placement is handled entirely by mergedProjects using daemon WebSocket data
1311
+ // (which has accurate real-time cwd from agentCwdMap). This avoids duplication
1312
+ // between orchestratorAgents and projectAgents having stale/conflicting cwd values.
1313
+ const repoProjects: Project[] = workspaceRepos.map((repo) => {
1314
+ const repoName = repo.githubFullName.split('/').pop() || repo.githubFullName;
1315
+ return {
1316
+ id: repo.id,
1317
+ path: repo.githubFullName,
1318
+ name: repoName,
1319
+ agents: [] as Agent[],
1320
+ lead: undefined,
1321
+ };
1322
+ });
1323
+ setProjects(repoProjects);
1324
+ // Set first repo as current if none selected
1325
+ if (!currentProject || !repoProjects.find(p => p.id === currentProject)) {
1326
+ setCurrentProject(repoProjects[0]?.id);
1327
+ }
1328
+ } else if (workspaces.length > 0) {
1329
+ // Single repo or no repos fetched yet - show workspace as single project
1330
+ // Only use orchestrator workspaces here (not cloud workspaces) since this path
1331
+ // maps workspace objects directly to projects with embedded agents
1332
+ const projectList: Project[] = workspaces.map((workspace) => ({
1333
+ id: workspace.id,
1334
+ path: workspace.path,
1335
+ name: workspace.name,
1336
+ agents: orchestratorAgents
1337
+ .filter((a) => a.workspaceId === workspace.id)
1338
+ .map((a) => ({
1339
+ name: a.name,
1340
+ status: a.status === 'running' ? 'online' : 'offline',
1341
+ isSpawned: true,
1342
+ cli: a.provider,
1343
+ cwd: a.cwd,
1344
+ })) as Agent[],
1345
+ lead: undefined,
1346
+ }));
1347
+ setProjects(projectList);
1348
+ setCurrentProject(activeWorkspaceId);
1349
+ } else if (isCloudMode && effectiveActiveWorkspaceId) {
1350
+ // Cloud mode with single repo or no repos yet - create a single project
1351
+ // from the active cloud workspace
1352
+ const activeWs = effectiveWorkspaces.find(w => w.id === effectiveActiveWorkspaceId);
1353
+ if (activeWs) {
1354
+ const projectList: Project[] = [{
1355
+ id: activeWs.id,
1356
+ path: activeWs.path,
1357
+ name: activeWs.name,
1358
+ agents: [] as Agent[],
1359
+ lead: undefined,
1360
+ }];
1361
+ setProjects(projectList);
1362
+ setCurrentProject(activeWs.id);
1363
+ }
1364
+ }
1365
+ }
1366
+ }, [hasWorkspaces, workspaces, orchestratorAgents, activeWorkspaceId, workspaceRepos, effectiveActiveWorkspaceId, currentProject, isCloudMode, effectiveWorkspaces]);
1367
+
1368
+ // Fetch bridge/project data for multi-project mode
1369
+ useEffect(() => {
1370
+ if (hasWorkspaces) return; // Skip if using orchestrator or cloud workspaces
1371
+
1372
+ const fetchProjects = async () => {
1373
+ const result = await api.getBridgeData();
1374
+ if (result.success && result.data) {
1375
+ // Bridge data returns { projects, messages, connected }
1376
+ const bridgeData = result.data as {
1377
+ projects?: Array<{
1378
+ id: string;
1379
+ name?: string;
1380
+ path: string;
1381
+ connected?: boolean;
1382
+ agents?: Array<{ name: string; status: string; task?: string; cli?: string }>;
1383
+ lead?: { name: string; connected: boolean };
1384
+ }>;
1385
+ connected?: boolean;
1386
+ currentProjectPath?: string;
1387
+ };
1388
+
1389
+ if (bridgeData.projects && bridgeData.projects.length > 0) {
1390
+ const projectList: Project[] = bridgeData.projects.map((p) => ({
1391
+ id: p.id,
1392
+ path: p.path,
1393
+ name: p.name || p.path.split('/').pop(),
1394
+ agents: (p.agents || [])
1395
+ // Filter out human users (cli === 'dashboard') from project agents
1396
+ // Humans should appear in Direct Messages, not under projects
1397
+ .filter((a) => a.cli !== 'dashboard')
1398
+ .map((a) => ({
1399
+ name: a.name,
1400
+ status: a.status === 'online' || a.status === 'active' ? 'online' : 'offline',
1401
+ currentTask: a.task,
1402
+ cli: a.cli,
1403
+ })) as Agent[],
1404
+ lead: p.lead,
1405
+ }));
1406
+ setProjects(projectList);
1407
+ // Set first project as current if none selected
1408
+ if (!currentProject && projectList.length > 0) {
1409
+ setCurrentProject(projectList[0].id);
1410
+ }
1411
+ }
1412
+ }
1413
+ };
1414
+
1415
+ // Fetch immediately on mount
1416
+ fetchProjects();
1417
+ // Poll for updates
1418
+ const interval = setInterval(fetchProjects, 5000);
1419
+ return () => clearInterval(interval);
1420
+ }, [hasWorkspaces, currentProject]);
1421
+
1422
+ // Bridge-level agents (like Architect) that should be shown separately
1423
+ const BRIDGE_AGENT_NAMES = ['architect'];
1424
+
1425
+ // Separate bridge-level agents from regular project agents
1426
+ // Filter out human users - they should appear in Direct Messages, not merged into projects
1427
+ const { bridgeAgents, projectAgents } = useMemo(() => {
1428
+ const bridge: Agent[] = [];
1429
+ const project: Agent[] = [];
1430
+
1431
+ for (const agent of agents) {
1432
+ // Skip human users - they shouldn't be merged into projects
1433
+ if (agent.isHuman || agent.cli === 'dashboard') {
1434
+ continue;
1435
+ }
1436
+ if (BRIDGE_AGENT_NAMES.includes(agent.name.toLowerCase())) {
1437
+ bridge.push(agent);
1438
+ } else {
1439
+ project.push(agent);
1440
+ }
1441
+ }
1442
+
1443
+ return { bridgeAgents: bridge, projectAgents: project };
1444
+ }, [agents]);
1445
+
1446
+ // Merge local daemon agents into their project when we have bridge projects
1447
+ // This prevents agents from appearing under "Local" instead of their project folder
1448
+ const mergedProjects = useMemo(() => {
1449
+ if (projects.length === 0) {
1450
+ return projects;
1451
+ }
1452
+
1453
+ if (workspaceRepos.length > 1) {
1454
+ // Multi-repo: assign agents to projects by cwd match
1455
+ // Agents without cwd sit outside any repo folder (workspace-level)
1456
+ // Combine projectAgents (WebSocket) with orchestratorAgents (polling) to cover
1457
+ // both data sources - WebSocket agents have cwd from getAllData(), orchestrator
1458
+ // agents have cwd from /api/spawned polling.
1459
+ const allAgents: Agent[] = [...projectAgents];
1460
+ const seenNames = new Set(projectAgents.map(a => a.name.toLowerCase()));
1461
+ for (const oa of orchestratorAgents) {
1462
+ if (!seenNames.has(oa.name.toLowerCase())) {
1463
+ seenNames.add(oa.name.toLowerCase());
1464
+ allAgents.push({
1465
+ name: oa.name,
1466
+ status: oa.status === 'running' ? 'online' : 'offline',
1467
+ isSpawned: true,
1468
+ cli: oa.provider,
1469
+ cwd: oa.cwd,
1470
+ } as Agent);
1471
+ }
1472
+ }
1473
+
1474
+ if (allAgents.length === 0) return projects;
1475
+
1476
+ const repoNames = new Set(projects.map(p => p.name));
1477
+ const repoProjects = projects.map((project) => {
1478
+ const repoName = project.name;
1479
+ const matchingAgents = allAgents.filter((a) => a.cwd === repoName);
1480
+ return {
1481
+ ...project,
1482
+ agents: [...project.agents, ...matchingAgents],
1483
+ };
1484
+ });
1485
+
1486
+ // Collect workspace-level agents into a virtual "Workspace" project:
1487
+ // - Agents without cwd (spawned at workspace root or relay-protocol spawned)
1488
+ // - Agents with cwd that doesn't match any repo (prevents orphaned agents)
1489
+ const placedAgentNames = new Set(repoProjects.flatMap(p => p.agents.map(a => a.name.toLowerCase())));
1490
+ const workspaceAgents = allAgents.filter((a) => {
1491
+ if (placedAgentNames.has(a.name.toLowerCase())) return false;
1492
+ return !a.cwd || !repoNames.has(a.cwd);
1493
+ });
1494
+
1495
+ if (workspaceAgents.length > 0) {
1496
+ const workspaceProject: Project = {
1497
+ id: '__workspace__',
1498
+ path: '/workspace',
1499
+ name: 'Workspace',
1500
+ agents: workspaceAgents,
1501
+ };
1502
+ return [workspaceProject, ...repoProjects];
1503
+ }
1504
+
1505
+ return repoProjects;
1506
+ }
1507
+
1508
+ if (projectAgents.length === 0) return projects;
1509
+
1510
+ // Single-repo / bridge mode: merge into current/first project
1511
+ return projects.map((project, index) => {
1512
+ const isCurrentDaemonProject = index === 0 || project.id === currentProject;
1513
+
1514
+ if (isCurrentDaemonProject) {
1515
+ const existingNames = new Set(project.agents.map((a) => a.name.toLowerCase()));
1516
+ const newAgents = projectAgents.filter((a) => !existingNames.has(a.name.toLowerCase()));
1517
+
1518
+ return {
1519
+ ...project,
1520
+ agents: [...project.agents, ...newAgents],
1521
+ };
1522
+ }
1523
+
1524
+ return project;
1525
+ });
1526
+ }, [projects, projectAgents, orchestratorAgents, currentProject, workspaceRepos.length]);
1527
+
1528
+ // Determine if local agents should be shown separately
1529
+ // Only show "Local" folder if we don't have bridge projects to merge them into
1530
+ // But always include human users so they appear in the sidebar for DM
1531
+ const localAgentsForSidebar = useMemo(() => {
1532
+ // In cloud mode, filter human users to only show workspace members
1533
+ // This prevents users from other workspaces appearing in Direct Messages
1534
+ const filterHumansByWorkspace = (agents: Agent[]) => {
1535
+ if (!isCloudMode || memberUsernames.size === 0) {
1536
+ return agents;
1537
+ }
1538
+ return agents.filter(agent =>
1539
+ !agent.isHuman || memberUsernames.has(agent.name.toLowerCase())
1540
+ );
1541
+ };
1542
+
1543
+ // Human users should always be shown in sidebar for DM access
1544
+ // Source from unfiltered `agents` since projectAgents filters out humans
1545
+ const humanUsers = filterHumansByWorkspace(agents).filter(a => a.isHuman);
1546
+
1547
+ if (mergedProjects.length > 0) {
1548
+ // Don't show AI agents separately - they're merged into projects
1549
+ // But keep human users visible for DM conversations
1550
+ return humanUsers;
1551
+ }
1552
+ // Return all agents (AI + human), with human users filtered by workspace membership
1553
+ return [...filterHumansByWorkspace(projectAgents), ...humanUsers];
1554
+ }, [mergedProjects, projectAgents, agents, isCloudMode, memberUsernames]);
1555
+
1556
+ // Handle workspace selection
1557
+ const handleWorkspaceSelect = useCallback(async (workspace: Workspace) => {
1558
+ try {
1559
+ await switchWorkspace(workspace.id);
1560
+ } catch (err) {
1561
+ console.error('Failed to switch workspace:', err);
1562
+ }
1563
+ }, [switchWorkspace]);
1564
+
1565
+ // Handle add workspace
1566
+ const handleAddWorkspace = useCallback(async (path: string, name?: string) => {
1567
+ setIsAddingWorkspace(true);
1568
+ setAddWorkspaceError(null);
1569
+ try {
1570
+ await addWorkspace(path, name);
1571
+ setIsAddWorkspaceOpen(false);
1572
+ } catch (err) {
1573
+ setAddWorkspaceError(err instanceof Error ? err.message : 'Failed to add workspace');
1574
+ throw err;
1575
+ } finally {
1576
+ setIsAddingWorkspace(false);
1577
+ }
1578
+ }, [addWorkspace]);
1579
+
1580
+ // Handle project selection (also switches workspace if using orchestrator)
1581
+ const handleProjectSelect = useCallback((project: Project) => {
1582
+ setCurrentProject(project.id);
1583
+ // Switch to DM view mode and clear channel selection
1584
+ setViewMode('local');
1585
+ setSelectedChannelId(undefined);
1586
+
1587
+ // Track as recently accessed
1588
+ addRecentRepo(project);
1589
+
1590
+ // Switch workspace if using orchestrator
1591
+ if (workspaces.length > 0) {
1592
+ switchWorkspace(project.id).catch((err) => {
1593
+ console.error('Failed to switch workspace:', err);
1594
+ });
1595
+ }
1596
+
1597
+ if (project.agents.length > 0) {
1598
+ selectAgent(project.agents[0].name);
1599
+ setCurrentChannel(project.agents[0].name);
1600
+ }
1601
+ closeSidebarOnMobile();
1602
+ }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, workspaces.length, switchWorkspace, addRecentRepo]);
1603
+
1604
+ // Handle agent selection
1605
+ const handleAgentSelect = useCallback((agent: Agent) => {
1606
+ // Switch to DM view mode and clear channel selection
1607
+ setViewMode('local');
1608
+ setSelectedChannelId(undefined);
1609
+ selectAgent(agent.name);
1610
+ setCurrentChannel(agent.name);
1611
+ navigateToAgent(agent.name);
1612
+ closeSidebarOnMobile();
1613
+ }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, navigateToAgent]);
1614
+
1615
+ // Handle spawn button click
1616
+ const handleSpawnClick = useCallback(() => {
1617
+ setSpawnError(null);
1618
+ setIsSpawnModalOpen(true);
1619
+ }, []);
1620
+
1621
+ // Handle settings click - opens full settings page
1622
+ const handleSettingsClick = useCallback(() => {
1623
+ setSettingsInitialTab('dashboard');
1624
+ setIsFullSettingsOpen(true);
1625
+ navigateToSettings('dashboard');
1626
+ }, [navigateToSettings]);
1627
+
1628
+ // Handle workspace settings click - opens full settings page with workspace tab
1629
+ const handleWorkspaceSettingsClick = useCallback(() => {
1630
+ setSettingsInitialTab('workspace');
1631
+ setIsFullSettingsOpen(true);
1632
+ navigateToSettings('workspace');
1633
+ }, [navigateToSettings]);
1634
+
1635
+ // Handle billing click - opens full settings page with billing tab
1636
+ const handleBillingClick = useCallback(() => {
1637
+ setSettingsInitialTab('billing');
1638
+ setIsFullSettingsOpen(true);
1639
+ navigateToSettings('billing');
1640
+ }, [navigateToSettings]);
1641
+
1642
+ // Handle history click
1643
+ const handleHistoryClick = useCallback(() => {
1644
+ setIsHistoryOpen(true);
1645
+ }, []);
1646
+
1647
+ // Handle new conversation click
1648
+ const handleNewConversationClick = useCallback(() => {
1649
+ setIsNewConversationOpen(true);
1650
+ }, []);
1651
+
1652
+ // Handle coordinator click
1653
+ const handleCoordinatorClick = useCallback(() => {
1654
+ setIsCoordinatorOpen(true);
1655
+ }, []);
1656
+
1657
+ // Open a DM with a human user from the sidebar
1658
+ const handleHumanSelect = useCallback((human: Agent) => {
1659
+ // Switch to DM view mode and clear channel selection
1660
+ setViewMode('local');
1661
+ setSelectedChannelId(undefined);
1662
+ setCurrentChannel(human.name);
1663
+ markDmSeen(human.name);
1664
+ navigateToDm(human.name);
1665
+ closeSidebarOnMobile();
1666
+ }, [closeSidebarOnMobile, markDmSeen, setCurrentChannel, navigateToDm]);
1667
+
1668
+ // Handle channel member click - switch to DM with that member
1669
+ const handleChannelMemberClick = useCallback((memberId: string, entityType: 'user' | 'agent') => {
1670
+ // Don't navigate to self
1671
+ if (memberId === currentUser?.displayName) return;
1672
+
1673
+ // Switch from channel view to local (DM) view
1674
+ setViewMode('local');
1675
+ setSelectedChannelId(undefined);
1676
+
1677
+ // Select the agent or user
1678
+ if (entityType === 'agent') {
1679
+ selectAgent(memberId);
1680
+ setCurrentChannel(memberId);
1681
+ } else {
1682
+ // For users, just set the channel
1683
+ setCurrentChannel(memberId);
1684
+ }
1685
+
1686
+ closeSidebarOnMobile();
1687
+ }, [currentUser?.displayName, selectAgent, setCurrentChannel, closeSidebarOnMobile]);
1688
+
1689
+ // =============================================================================
1690
+ // Channel V1 Handlers
1691
+ // =============================================================================
1692
+
1693
+ // Default channels that should always be visible - stable reference
1694
+ const defaultChannels = useMemo<Channel[]>(() => [
1695
+ {
1696
+ id: '#general',
1697
+ name: 'general',
1698
+ description: 'General discussion for all agents',
1699
+ visibility: 'public',
1700
+ memberCount: 0,
1701
+ unreadCount: 0,
1702
+ hasMentions: false,
1703
+ createdAt: '2024-01-01T00:00:00.000Z', // Static date for stability
1704
+ status: 'active',
1705
+ createdBy: 'system',
1706
+ isDm: false,
1707
+ },
1708
+ {
1709
+ id: '#engineering',
1710
+ name: 'engineering',
1711
+ description: 'Engineering discussion',
1712
+ visibility: 'public',
1713
+ memberCount: 0,
1714
+ unreadCount: 0,
1715
+ hasMentions: false,
1716
+ createdAt: '2024-01-01T00:00:00.000Z', // Static date for stability
1717
+ status: 'active',
1718
+ createdBy: 'system',
1719
+ isDm: false,
1720
+ },
1721
+ ], []);
1722
+
1723
+ // Load channels on mount (they're always visible in sidebar, collapsed by default)
1724
+ useEffect(() => {
1725
+ // Not in cloud mode or no workspace - show default channels only
1726
+ if (!isCloudMode || !effectiveActiveWorkspaceId) {
1727
+ setChannelsList(defaultChannels);
1728
+ setArchivedChannelsList([]);
1729
+ return;
1730
+ }
1731
+
1732
+ // Cloud mode with workspace - fetch from API and merge with defaults
1733
+ setChannelsList(defaultChannels);
1734
+ setArchivedChannelsList([]);
1735
+ setIsChannelsLoading(true);
1736
+
1737
+ const fetchChannels = async () => {
1738
+ try {
1739
+ const response = await listChannels(effectiveActiveWorkspaceId);
1740
+ setChannelListsFromResponse(response);
1741
+ } catch (err) {
1742
+ console.error('Failed to fetch channels:', err);
1743
+ } finally {
1744
+ setIsChannelsLoading(false);
1745
+ }
1746
+ };
1747
+
1748
+ fetchChannels();
1749
+ }, [effectiveActiveWorkspaceId, isCloudMode, defaultChannels, setChannelListsFromResponse]);
1750
+
1751
+ // Load messages when a channel is selected (persisted + live)
1752
+ useEffect(() => {
1753
+ if (!selectedChannelId || viewMode !== 'channels') return;
1754
+ // Activity feed is a virtual channel - don't fetch from API
1755
+ if (selectedChannelId === ACTIVITY_FEED_ID) return;
1756
+ // Don't fetch in cloud mode until we have a workspace ID
1757
+ if (isCloudMode && !effectiveActiveWorkspaceId) return;
1758
+
1759
+ // Check if we already have messages cached
1760
+ const existing = channelMessageMap[selectedChannelId] ?? [];
1761
+ if (existing.length > 0) {
1762
+ setChannelMessages(existing);
1763
+ setHasMoreMessages(false);
1764
+ } else if (!fetchedChannelsRef.current.has(selectedChannelId)) {
1765
+ // Only fetch if we haven't already fetched this channel (prevents infinite loop)
1766
+ const channelToFetch = selectedChannelId;
1767
+ fetchedChannelsRef.current.add(channelToFetch);
1768
+ (async () => {
1769
+ try {
1770
+ const response = await getMessages(effectiveActiveWorkspaceId || 'local', channelToFetch, { limit: 200 });
1771
+ setChannelMessageMap(prev => ({ ...prev, [channelToFetch]: response.messages }));
1772
+ setChannelMessages(response.messages);
1773
+ setHasMoreMessages(response.hasMore);
1774
+ } catch (err) {
1775
+ console.error('Failed to fetch channel messages:', err);
1776
+ // Remove from fetched set so it can be retried on next navigation
1777
+ fetchedChannelsRef.current.delete(channelToFetch);
1778
+ setChannelMessages([]);
1779
+ setHasMoreMessages(false);
1780
+ }
1781
+ })();
1782
+ } else {
1783
+ // Already fetched but no messages - show empty state
1784
+ setChannelMessages([]);
1785
+ setHasMoreMessages(false);
1786
+ }
1787
+
1788
+ setChannelUnreadState(undefined);
1789
+ setChannelsList(prev =>
1790
+ prev.map(c =>
1791
+ c.id === selectedChannelId ? { ...c, unreadCount: 0, hasMentions: false } : c
1792
+ )
1793
+ );
1794
+ }, [selectedChannelId, viewMode, effectiveActiveWorkspaceId]); // Removed channelMessageMap to prevent infinite loop
1795
+
1796
+ // Channel selection handler - also joins the channel in local mode
1797
+ const handleSelectChannel = useCallback(async (channel: Channel) => {
1798
+ setSelectedChannelId(channel.id);
1799
+ navigateToChannel(channel.id);
1800
+ closeSidebarOnMobile();
1801
+
1802
+ // Join the channel via the daemon (needed for local mode)
1803
+ // This ensures the user is a member before sending messages
1804
+ try {
1805
+ const { joinChannel: joinChannelApi } = await import('./channels');
1806
+ await joinChannelApi(effectiveActiveWorkspaceId || 'local', channel.id);
1807
+ } catch (err) {
1808
+ console.error('Failed to join channel:', err);
1809
+ }
1810
+ }, [closeSidebarOnMobile, effectiveActiveWorkspaceId, navigateToChannel]);
1811
+
1812
+ // Create channel handler - opens the create channel modal
1813
+ const handleCreateChannel = useCallback(() => {
1814
+ setIsCreateChannelOpen(true);
1815
+ }, []);
1816
+
1817
+ // Handler for creating a new channel via API
1818
+ const handleCreateChannelSubmit = useCallback(async (request: CreateChannelRequest) => {
1819
+ if (!effectiveActiveWorkspaceId) return;
1820
+ setIsCreatingChannel(true);
1821
+ try {
1822
+ const result = await createChannel(effectiveActiveWorkspaceId, request);
1823
+ // Refresh channels list after successful creation
1824
+ const response = await listChannels(effectiveActiveWorkspaceId);
1825
+ setChannelListsFromResponse(response);
1826
+ if (result.channel?.id) {
1827
+ setSelectedChannelId(result.channel.id);
1828
+ }
1829
+ setIsCreateChannelOpen(false);
1830
+ } catch (err) {
1831
+ console.error('Failed to create channel:', err);
1832
+ // Keep modal open on error so user can retry
1833
+ } finally {
1834
+ setIsCreatingChannel(false);
1835
+ }
1836
+ }, [effectiveActiveWorkspaceId]);
1837
+
1838
+ // Handler for opening the invite to channel modal
1839
+ const handleInviteToChannel = useCallback((channel: Channel) => {
1840
+ setInviteChannelTarget(channel);
1841
+ setIsInviteChannelOpen(true);
1842
+ }, []);
1843
+
1844
+ // Handler for inviting members to a channel
1845
+ // Note: InviteToChannelModal is given agents as availableMembers, so all invitees are agents
1846
+ const handleInviteSubmit = useCallback(async (members: string[]) => {
1847
+ if (!inviteChannelTarget) return;
1848
+ setIsInvitingToChannel(true);
1849
+ try {
1850
+ // Call the invite API endpoint with CSRF token
1851
+ const csrfToken = getCsrfToken();
1852
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
1853
+ if (csrfToken) {
1854
+ headers['X-CSRF-Token'] = csrfToken;
1855
+ }
1856
+
1857
+ // Send invites with type info - all members from invite modal are agents
1858
+ const invites = members.map(name => ({ id: name, type: 'agent' as const }));
1859
+
1860
+ const response = await fetch('/api/channels/invite', {
1861
+ method: 'POST',
1862
+ headers,
1863
+ credentials: 'include',
1864
+ body: JSON.stringify({
1865
+ channel: inviteChannelTarget.name,
1866
+ invites,
1867
+ workspaceId: effectiveActiveWorkspaceId,
1868
+ }),
1869
+ });
1870
+ if (!response.ok) {
1871
+ throw new Error('Failed to invite members');
1872
+ }
1873
+ setIsInviteChannelOpen(false);
1874
+ setInviteChannelTarget(null);
1875
+ } catch (err) {
1876
+ console.error('Failed to invite to channel:', err);
1877
+ } finally {
1878
+ setIsInvitingToChannel(false);
1879
+ }
1880
+ }, [inviteChannelTarget, effectiveActiveWorkspaceId]);
1881
+
1882
+ // Join channel handler
1883
+ const handleJoinChannel = useCallback(async (channelId: string) => {
1884
+ if (!effectiveActiveWorkspaceId) return;
1885
+ try {
1886
+ const { joinChannel } = await import('./channels');
1887
+ await joinChannel(effectiveActiveWorkspaceId, channelId);
1888
+ // Refresh channels list
1889
+ const response = await listChannels(effectiveActiveWorkspaceId);
1890
+ setChannelListsFromResponse(response);
1891
+ } catch (err) {
1892
+ console.error('Failed to join channel:', err);
1893
+ }
1894
+ }, [effectiveActiveWorkspaceId, setChannelListsFromResponse]);
1895
+
1896
+ // Leave channel handler
1897
+ const handleLeaveChannel = useCallback(async (channel: Channel) => {
1898
+ if (!effectiveActiveWorkspaceId) return;
1899
+ try {
1900
+ const { leaveChannel } = await import('./channels');
1901
+ await leaveChannel(effectiveActiveWorkspaceId, channel.id);
1902
+ // Clear selection if leaving current channel
1903
+ if (selectedChannelId === channel.id) {
1904
+ setSelectedChannelId(undefined);
1905
+ }
1906
+ // Refresh channels list
1907
+ const response = await listChannels(effectiveActiveWorkspaceId);
1908
+ setChannelListsFromResponse(response);
1909
+ } catch (err) {
1910
+ console.error('Failed to leave channel:', err);
1911
+ }
1912
+ }, [effectiveActiveWorkspaceId, selectedChannelId, setChannelListsFromResponse]);
1913
+
1914
+ // Show members panel handler
1915
+ const handleShowMembers = useCallback(async () => {
1916
+ if (!selectedChannel || !effectiveActiveWorkspaceId) return;
1917
+ try {
1918
+ const members = await getChannelMembers(effectiveActiveWorkspaceId, selectedChannel.id);
1919
+ setChannelMembers(members);
1920
+ setShowMemberPanel(true);
1921
+ } catch (err) {
1922
+ console.error('Failed to load channel members:', err);
1923
+ }
1924
+ }, [selectedChannel, effectiveActiveWorkspaceId]);
1925
+
1926
+ // Remove member handler
1927
+ const handleRemoveMember = useCallback(async (memberId: string, memberType: 'user' | 'agent') => {
1928
+ if (!selectedChannel || !effectiveActiveWorkspaceId) return;
1929
+ try {
1930
+ await removeChannelMember(effectiveActiveWorkspaceId, selectedChannel.id, memberId, memberType);
1931
+ // Refresh members list
1932
+ const members = await getChannelMembers(effectiveActiveWorkspaceId, selectedChannel.id);
1933
+ setChannelMembers(members);
1934
+ } catch (err) {
1935
+ console.error('Failed to remove member:', err);
1936
+ }
1937
+ }, [selectedChannel, effectiveActiveWorkspaceId]);
1938
+
1939
+ // Add member handler (for MemberManagementPanel)
1940
+ const handleAddMember = useCallback(async (memberId: string, memberType: 'user' | 'agent', _role: 'admin' | 'member' | 'read_only') => {
1941
+ if (!selectedChannel || !effectiveActiveWorkspaceId) return;
1942
+ try {
1943
+ const csrfToken = getCsrfToken();
1944
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
1945
+ if (csrfToken) {
1946
+ headers['X-CSRF-Token'] = csrfToken;
1947
+ }
1948
+
1949
+ const response = await fetch('/api/channels/invite', {
1950
+ method: 'POST',
1951
+ headers,
1952
+ credentials: 'include',
1953
+ body: JSON.stringify({
1954
+ channel: selectedChannel.name,
1955
+ invites: [{ id: memberId, type: memberType }],
1956
+ workspaceId: effectiveActiveWorkspaceId,
1957
+ }),
1958
+ });
1959
+
1960
+ if (!response.ok) {
1961
+ throw new Error('Failed to add member');
1962
+ }
1963
+
1964
+ // Refresh members list
1965
+ const members = await getChannelMembers(effectiveActiveWorkspaceId, selectedChannel.id);
1966
+ setChannelMembers(members);
1967
+ } catch (err) {
1968
+ console.error('Failed to add member:', err);
1969
+ }
1970
+ }, [selectedChannel, effectiveActiveWorkspaceId]);
1971
+
1972
+ // Archive channel handler
1973
+ const handleArchiveChannel = useCallback(async (channel: Channel) => {
1974
+ if (!effectiveActiveWorkspaceId) return;
1975
+ try {
1976
+ const { archiveChannel } = await import('./channels');
1977
+ await archiveChannel(effectiveActiveWorkspaceId, channel.id);
1978
+ // Clear selection if archiving current channel
1979
+ if (selectedChannelId === channel.id) {
1980
+ setSelectedChannelId(undefined);
1981
+ }
1982
+ // Refresh channels list
1983
+ const response = await listChannels(effectiveActiveWorkspaceId);
1984
+ setChannelListsFromResponse(response);
1985
+ } catch (err) {
1986
+ console.error('Failed to archive channel:', err);
1987
+ }
1988
+ }, [effectiveActiveWorkspaceId, selectedChannelId, setChannelListsFromResponse]);
1989
+
1990
+ // Unarchive channel handler
1991
+ const handleUnarchiveChannel = useCallback(async (channel: Channel) => {
1992
+ if (!effectiveActiveWorkspaceId) return;
1993
+ try {
1994
+ const { unarchiveChannel } = await import('./channels');
1995
+ await unarchiveChannel(effectiveActiveWorkspaceId, channel.id);
1996
+ // Refresh channels list
1997
+ const response = await listChannels(effectiveActiveWorkspaceId);
1998
+ setChannelListsFromResponse(response);
1999
+ } catch (err) {
2000
+ console.error('Failed to unarchive channel:', err);
2001
+ }
2002
+ }, [effectiveActiveWorkspaceId, setChannelListsFromResponse]);
2003
+
2004
+ // Send message to channel handler
2005
+ const handleSendChannelMessage = useCallback(async (content: string, threadId?: string) => {
2006
+ if (!selectedChannelId) return;
2007
+
2008
+ const senderName = currentUser?.displayName || 'Dashboard';
2009
+ const optimisticMessage: ChannelApiMessage = {
2010
+ id: `local-${Date.now()}`,
2011
+ channelId: selectedChannelId,
2012
+ from: senderName,
2013
+ fromEntityType: 'user',
2014
+ content,
2015
+ timestamp: new Date().toISOString(),
2016
+ threadId,
2017
+ isRead: true,
2018
+ };
2019
+
2020
+ // Optimistic append; daemon will echo back via WS
2021
+ appendChannelMessage(selectedChannelId, optimisticMessage, { incrementUnread: false });
2022
+
2023
+ try {
2024
+ await sendChannelApiMessage(
2025
+ effectiveActiveWorkspaceId || 'local',
2026
+ selectedChannelId,
2027
+ { content, threadId }
2028
+ );
2029
+ } catch (err) {
2030
+ console.error('Failed to send channel message:', err);
2031
+ }
2032
+ }, [effectiveActiveWorkspaceId, selectedChannelId, currentUser?.displayName, appendChannelMessage]);
2033
+
2034
+ // Handle reaction toggle on a message
2035
+ const handleReaction = useCallback(async (messageId: string, emoji: string, hasReacted: boolean) => {
2036
+ // Optimistic update
2037
+ const userName = currentUser?.displayName || 'user';
2038
+ setReactionOverrides((prev) => {
2039
+ const next = new Map(prev);
2040
+ const msg = rawDataRef.current?.messages.find((m: Message) => m.id === messageId);
2041
+ const prevEntry = prev.get(messageId);
2042
+ const current = prevEntry?.reactions || msg?.reactions || [];
2043
+ let updated: Reaction[];
2044
+
2045
+ if (hasReacted) {
2046
+ updated = current
2047
+ .map((r: Reaction) =>
2048
+ r.emoji === emoji
2049
+ ? { ...r, count: r.count - 1, agents: r.agents.filter((a: string) => a !== userName) }
2050
+ : r
2051
+ )
2052
+ .filter((r: Reaction) => r.count > 0);
2053
+ } else {
2054
+ const existing = current.find((r: Reaction) => r.emoji === emoji);
2055
+ if (existing) {
2056
+ updated = current.map((r: Reaction) =>
2057
+ r.emoji === emoji
2058
+ ? { ...r, count: r.count + 1, agents: [...r.agents, userName] }
2059
+ : r
2060
+ );
2061
+ } else {
2062
+ updated = [...current, { emoji, count: 1, agents: [userName] }];
2063
+ }
2064
+ }
2065
+
2066
+ next.set(messageId, { reactions: updated, timestamp: Date.now() });
2067
+ return next;
2068
+ });
2069
+
2070
+ // Fire API call in background
2071
+ if (hasReacted) {
2072
+ api.removeReaction(messageId, emoji).catch(() => undefined);
2073
+ } else {
2074
+ api.addReaction(messageId, emoji).catch(() => undefined);
2075
+ }
2076
+ }, [currentUser?.displayName]);
2077
+
2078
+ // Load more messages (pagination) handler
2079
+ const handleLoadMoreMessages = useCallback(async () => {
2080
+ // Pagination not yet supported for daemon channels
2081
+ return;
2082
+ }, []);
2083
+
2084
+ // Mark channel as read handler (with debouncing via useRef)
2085
+ const markReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2086
+ const handleMarkChannelRead = useCallback((channelId: string) => {
2087
+ if (!effectiveActiveWorkspaceId) return;
2088
+
2089
+ // Clear existing timeout to debounce
2090
+ if (markReadTimeoutRef.current) {
2091
+ clearTimeout(markReadTimeoutRef.current);
2092
+ }
2093
+
2094
+ // Debounce the markRead call (500ms delay)
2095
+ markReadTimeoutRef.current = setTimeout(async () => {
2096
+ try {
2097
+ await markRead(effectiveActiveWorkspaceId, channelId);
2098
+ // Update local unread state
2099
+ setChannelUnreadState(undefined);
2100
+ // Update channel list unread counts
2101
+ setChannelsList(prev => prev.map(c =>
2102
+ c.id === channelId ? { ...c, unreadCount: 0, hasMentions: false } : c
2103
+ ));
2104
+ } catch (err) {
2105
+ console.error('Failed to mark channel as read:', err);
2106
+ }
2107
+ }, 500);
2108
+ }, [effectiveActiveWorkspaceId]);
2109
+
2110
+ // Auto-mark channel as read when viewing it
2111
+ useEffect(() => {
2112
+ if (!selectedChannelId || !channelUnreadState || channelUnreadState.count === 0) return;
2113
+ if (viewMode !== 'channels') return;
2114
+
2115
+ // Mark as read when channel is viewed and has unread messages
2116
+ handleMarkChannelRead(selectedChannelId);
2117
+ }, [selectedChannelId, channelUnreadState, viewMode, handleMarkChannelRead]);
2118
+
2119
+ // Cleanup markRead timeout on unmount
2120
+ useEffect(() => {
2121
+ return () => {
2122
+ if (markReadTimeoutRef.current) {
2123
+ clearTimeout(markReadTimeoutRef.current);
2124
+ }
2125
+ };
2126
+ }, []);
2127
+
2128
+ const handleDmAgentToggle = useCallback((agentName: string) => {
2129
+ if (!currentHuman) return;
2130
+ const humanName = currentHuman.name;
2131
+ const isSelected = (dmSelectedAgentsByHuman[humanName] ?? []).includes(agentName);
2132
+
2133
+ setDmSelectedAgentsByHuman((prev) => {
2134
+ const currentList = prev[humanName] ?? [];
2135
+ const nextList = isSelected
2136
+ ? currentList.filter((a) => a !== agentName)
2137
+ : [...currentList, agentName];
2138
+ return { ...prev, [humanName]: nextList };
2139
+ });
2140
+
2141
+ setDmRemovedAgentsByHuman((prev) => {
2142
+ const currentList = prev[humanName] ?? [];
2143
+ if (isSelected) {
2144
+ // Mark as removed so derived participants don't auto-readd
2145
+ return currentList.includes(agentName)
2146
+ ? prev
2147
+ : { ...prev, [humanName]: [...currentList, agentName] };
2148
+ }
2149
+ // Re-adding clears removal
2150
+ return { ...prev, [humanName]: currentList.filter((a) => a !== agentName) };
2151
+ });
2152
+ }, [currentHuman, dmSelectedAgentsByHuman]);
2153
+
2154
+ const handleDmSend = useCallback(async (content: string, attachmentIds?: string[]): Promise<boolean> => {
2155
+ if (!currentHuman) return false;
2156
+ const humanName = currentHuman.name;
2157
+
2158
+ // Always send to the human
2159
+ await sendMessage(humanName, content, undefined, attachmentIds);
2160
+
2161
+ // Only send to agents if they were explicitly selected for this conversation
2162
+ // Don't send to agents in pure 1:1 human conversations
2163
+ if (selectedDmAgents.length > 0) {
2164
+ for (const agent of selectedDmAgents) {
2165
+ await sendMessage(agent, content, undefined, attachmentIds);
2166
+ }
2167
+ }
2168
+
2169
+ return true;
2170
+ }, [currentHuman, selectedDmAgents, sendMessage]);
2171
+
2172
+ const handleMainComposerSend = useCallback(
2173
+ async (content: string, attachmentIds?: string[]) => {
2174
+ const recipient = currentChannel;
2175
+
2176
+ if (currentHuman) {
2177
+ return handleDmSend(content, attachmentIds);
2178
+ }
2179
+
2180
+ return sendMessage(recipient, content, undefined, attachmentIds);
2181
+ },
2182
+ [currentChannel, currentHuman, handleDmSend, sendMessage]
2183
+ );
2184
+
2185
+ const dmInviteCommands = useMemo(() => {
2186
+ if (!currentHuman) return [];
2187
+ return agents
2188
+ .filter((a) => !a.isHuman)
2189
+ .map((agent) => {
2190
+ const isSelected = (dmSelectedAgentsByHuman[currentHuman.name] ?? []).includes(agent.name);
2191
+ return {
2192
+ id: `dm-toggle-${currentHuman.name}-${agent.name}`,
2193
+ label: `${isSelected ? 'Remove' : 'Invite'} ${agent.name} in DM`,
2194
+ description: `DM with ${currentHuman.name}`,
2195
+ category: 'actions' as const,
2196
+ action: () => handleDmAgentToggle(agent.name),
2197
+ };
2198
+ });
2199
+ }, [agents, currentHuman, dmSelectedAgentsByHuman, handleDmAgentToggle]);
2200
+
2201
+ // Channel commands for command palette
2202
+ const channelCommands = useMemo(() => {
2203
+ const commands: Array<{
2204
+ id: string;
2205
+ label: string;
2206
+ description?: string;
2207
+ category: 'channels';
2208
+ shortcut?: string;
2209
+ action: () => void;
2210
+ }> = [];
2211
+
2212
+ // Switch to channels view
2213
+ commands.push({
2214
+ id: 'channels-view',
2215
+ label: 'Go to Channels',
2216
+ description: 'Switch to channel messaging view',
2217
+ category: 'channels',
2218
+ shortcut: '⌘⇧C',
2219
+ action: () => {
2220
+ setViewMode('channels');
2221
+ },
2222
+ });
2223
+
2224
+ // Create new channel
2225
+ commands.push({
2226
+ id: 'channels-create',
2227
+ label: 'Create Channel',
2228
+ description: 'Create a new messaging channel',
2229
+ category: 'channels',
2230
+ action: () => {
2231
+ setViewMode('channels');
2232
+ handleCreateChannel();
2233
+ },
2234
+ });
2235
+
2236
+ // Add each channel as a quick-switch command
2237
+ channelsList.forEach((channel) => {
2238
+ const unreadBadge = channel.unreadCount > 0 ? ` (${channel.unreadCount} unread)` : '';
2239
+ commands.push({
2240
+ id: `channel-switch-${channel.id}`,
2241
+ label: channel.isDm ? `@${channel.name}` : `#${channel.name}`,
2242
+ description: channel.description || `Switch to ${channel.isDm ? 'DM' : 'channel'}${unreadBadge}`,
2243
+ category: 'channels',
2244
+ action: () => {
2245
+ setViewMode('channels');
2246
+ setSelectedChannelId(channel.id);
2247
+ },
2248
+ });
2249
+ });
2250
+
2251
+ return commands;
2252
+ }, [channelsList, handleCreateChannel]);
2253
+
2254
+ // Handle send from new conversation modal - select the channel after sending
2255
+ const handleNewConversationSend = useCallback(async (to: string, content: string): Promise<boolean> => {
2256
+ const success = await sendMessage(to, content);
2257
+ if (success) {
2258
+ // Switch to the channel/agent we just messaged
2259
+ const targetAgent = agents.find((a) => a.name === to);
2260
+ if (targetAgent) {
2261
+ selectAgent(targetAgent.name);
2262
+ setCurrentChannel(targetAgent.name);
2263
+ } else {
2264
+ setCurrentChannel(to);
2265
+ }
2266
+ }
2267
+ return success;
2268
+ }, [sendMessage, selectAgent, setCurrentChannel, agents]);
2269
+
2270
+ // Handle server reconnect (restart workspace)
2271
+ const handleServerReconnect = useCallback(async (serverId: string) => {
2272
+ if (isCloudMode) {
2273
+ try {
2274
+ const result = await cloudApi.restartWorkspace(serverId);
2275
+ if (result.success) {
2276
+ // Update the fleet servers state to show the server is restarting
2277
+ setFleetServers(prev => prev.map(s =>
2278
+ s.id === serverId ? { ...s, status: 'connecting' as const } : s
2279
+ ));
2280
+ // Refresh cloud workspaces after a short delay to get updated status
2281
+ setTimeout(async () => {
2282
+ try {
2283
+ const workspacesResult = await cloudApi.getWorkspaceSummary();
2284
+ if (workspacesResult.success && workspacesResult.data.workspaces) {
2285
+ setCloudWorkspaces(workspacesResult.data.workspaces);
2286
+ }
2287
+ } catch (err) {
2288
+ console.error('Failed to refresh workspaces after reconnect:', err);
2289
+ }
2290
+ }, 2000);
2291
+ } else {
2292
+ console.error('Failed to restart workspace:', result.error);
2293
+ }
2294
+ } catch (err) {
2295
+ console.error('Failed to reconnect to server:', err);
2296
+ }
2297
+ } else {
2298
+ // For orchestrator mode, attempt to reconnect by removing and re-adding the workspace
2299
+ console.warn('Server reconnect not fully supported in orchestrator mode');
2300
+ // Refresh the workspace list as a fallback
2301
+ // The orchestrator's WebSocket will handle reconnection automatically
2302
+ }
2303
+ }, [isCloudMode]);
2304
+
2305
+ // Handle spawn agent
2306
+ const handleSpawn = useCallback(async (config: SpawnConfig): Promise<boolean> => {
2307
+ setIsSpawning(true);
2308
+ setSpawnError(null);
2309
+ try {
2310
+ // Use orchestrator if workspaces are available
2311
+ if (workspaces.length > 0 && activeWorkspaceId) {
2312
+ await orchestratorSpawnAgent(config.name, undefined, config.command, config.cwd);
2313
+ return true;
2314
+ }
2315
+
2316
+ // Fallback to legacy API
2317
+ const result = await api.spawnAgent({
2318
+ name: config.name,
2319
+ cli: config.command,
2320
+ cwd: config.cwd,
2321
+ team: config.team,
2322
+ shadowMode: config.shadowMode,
2323
+ shadowOf: config.shadowOf,
2324
+ shadowAgent: config.shadowAgent,
2325
+ shadowTriggers: config.shadowTriggers,
2326
+ shadowSpeakOn: config.shadowSpeakOn,
2327
+ });
2328
+ if (!result.success) {
2329
+ setSpawnError(result.error || 'Failed to spawn agent');
2330
+ return false;
2331
+ }
2332
+ return true;
2333
+ } catch (err) {
2334
+ setSpawnError(err instanceof Error ? err.message : 'Failed to spawn agent');
2335
+ return false;
2336
+ } finally {
2337
+ setIsSpawning(false);
2338
+ }
2339
+ }, [workspaces.length, activeWorkspaceId, orchestratorSpawnAgent]);
2340
+
2341
+ // Handle release/kill agent
2342
+ const handleReleaseAgent = useCallback(async (agent: Agent) => {
2343
+ if (!agent.isSpawned) return;
2344
+
2345
+ const confirmed = window.confirm(`Are you sure you want to release agent "${agent.name}"?`);
2346
+ if (!confirmed) return;
2347
+
2348
+ try {
2349
+ // Use orchestrator if workspaces are available
2350
+ if (workspaces.length > 0 && activeWorkspaceId) {
2351
+ await orchestratorStopAgent(agent.name);
2352
+ return;
2353
+ }
2354
+
2355
+ // Fallback to legacy API
2356
+ const result = await api.releaseAgent(agent.name);
2357
+ if (!result.success) {
2358
+ console.error('Failed to release agent:', result.error);
2359
+ }
2360
+ } catch (err) {
2361
+ console.error('Failed to release agent:', err);
2362
+ }
2363
+ }, [workspaces.length, activeWorkspaceId, orchestratorStopAgent]);
2364
+
2365
+ // Handle logs click - open log viewer panel
2366
+ const handleLogsClick = useCallback((agent: Agent) => {
2367
+ setLogViewerAgent(agent);
2368
+ }, []);
2369
+
2370
+ // Fetch fleet servers periodically when fleet view is active
2371
+ useEffect(() => {
2372
+ if (!isFleetViewActive) return;
2373
+
2374
+ const fetchFleetServers = async () => {
2375
+ const result = await api.getFleetServers();
2376
+ if (result.success && result.data) {
2377
+ // Convert FleetServer to ServerInfo format
2378
+ const servers: ServerInfo[] = result.data.servers.map((s) => ({
2379
+ id: s.id,
2380
+ name: s.name,
2381
+ url: s.id === 'local' ? window.location.origin : `http://${s.id}`,
2382
+ status: s.status === 'healthy' ? 'online' : s.status === 'degraded' ? 'degraded' : 'offline',
2383
+ agentCount: s.agents.length,
2384
+ uptime: s.uptime,
2385
+ lastSeen: s.lastHeartbeat,
2386
+ }));
2387
+ setFleetServers(servers);
2388
+ }
2389
+ };
2390
+
2391
+ fetchFleetServers();
2392
+ const interval = setInterval(fetchFleetServers, 5000);
2393
+ return () => clearInterval(interval);
2394
+ }, [isFleetViewActive]);
2395
+
2396
+ // Fetch decisions periodically when queue is open
2397
+ useEffect(() => {
2398
+ if (!isDecisionQueueOpen) return;
2399
+
2400
+ const fetchDecisions = async () => {
2401
+ const result = await api.getDecisions();
2402
+ if (result.success && result.data) {
2403
+ setDecisions(result.data.decisions.map(convertApiDecision));
2404
+ }
2405
+ };
2406
+
2407
+ fetchDecisions();
2408
+ const interval = setInterval(fetchDecisions, 5000);
2409
+ return () => clearInterval(interval);
2410
+ }, [isDecisionQueueOpen]);
2411
+
2412
+ // Decision queue handlers
2413
+ const handleDecisionApprove = useCallback(async (decisionId: string, optionId?: string) => {
2414
+ setDecisionProcessing((prev) => ({ ...prev, [decisionId]: true }));
2415
+ try {
2416
+ const result = await api.approveDecision(decisionId, optionId);
2417
+ if (result.success) {
2418
+ setDecisions((prev) => prev.filter((d) => d.id !== decisionId));
2419
+ } else {
2420
+ console.error('Failed to approve decision:', result.error);
2421
+ }
2422
+ } catch (err) {
2423
+ console.error('Failed to approve decision:', err);
2424
+ } finally {
2425
+ setDecisionProcessing((prev) => ({ ...prev, [decisionId]: false }));
2426
+ }
2427
+ }, []);
2428
+
2429
+ const handleDecisionReject = useCallback(async (decisionId: string, reason?: string) => {
2430
+ setDecisionProcessing((prev) => ({ ...prev, [decisionId]: true }));
2431
+ try {
2432
+ const result = await api.rejectDecision(decisionId, reason);
2433
+ if (result.success) {
2434
+ setDecisions((prev) => prev.filter((d) => d.id !== decisionId));
2435
+ } else {
2436
+ console.error('Failed to reject decision:', result.error);
2437
+ }
2438
+ } catch (err) {
2439
+ console.error('Failed to reject decision:', err);
2440
+ } finally {
2441
+ setDecisionProcessing((prev) => ({ ...prev, [decisionId]: false }));
2442
+ }
2443
+ }, []);
2444
+
2445
+ const handleDecisionDismiss = useCallback(async (decisionId: string) => {
2446
+ const result = await api.dismissDecision(decisionId);
2447
+ if (result.success) {
2448
+ setDecisions((prev) => prev.filter((d) => d.id !== decisionId));
2449
+ }
2450
+ }, []);
2451
+
2452
+ // Task creation handler - creates bead and sends relay notification
2453
+ const handleTaskCreate = useCallback(async (task: TaskCreateRequest) => {
2454
+ setIsCreatingTask(true);
2455
+ try {
2456
+ // Map UI priority to beads priority number
2457
+ const beadsPriority = PRIORITY_CONFIG[task.priority].beadsPriority;
2458
+
2459
+ // Create bead via API
2460
+ const result = await api.createBead({
2461
+ title: task.title,
2462
+ assignee: task.agentName,
2463
+ priority: beadsPriority,
2464
+ type: 'task',
2465
+ });
2466
+
2467
+ if (result.success && result.data?.bead) {
2468
+ // Send relay notification to agent (non-interrupting)
2469
+ await api.sendRelayMessage({
2470
+ to: task.agentName,
2471
+ content: `📋 New task assigned: "${task.title}" (P${beadsPriority})\nCheck \`bd ready\` for details.`,
2472
+ });
2473
+ console.log('Task created:', result.data.bead.id);
2474
+ } else {
2475
+ console.error('Failed to create task bead:', result.error);
2476
+ throw new Error(result.error || 'Failed to create task');
2477
+ }
2478
+ } catch (err) {
2479
+ console.error('Failed to create task:', err);
2480
+ throw err;
2481
+ } finally {
2482
+ setIsCreatingTask(false);
2483
+ }
2484
+ }, []);
2485
+
2486
+ // Handle command palette
2487
+ const handleCommandPaletteOpen = useCallback(() => {
2488
+ setIsCommandPaletteOpen(true);
2489
+ }, []);
2490
+
2491
+ const handleCommandPaletteClose = useCallback(() => {
2492
+ setIsCommandPaletteOpen(false);
2493
+ }, []);
2494
+
2495
+ // Persist settings changes
2496
+ useEffect(() => {
2497
+ saveSettingsToStorage(settings);
2498
+ }, [settings]);
2499
+
2500
+ // Apply theme to document
2501
+ React.useEffect(() => {
2502
+ const applyTheme = (theme: 'light' | 'dark' | 'system') => {
2503
+ let effectiveTheme: 'light' | 'dark';
2504
+
2505
+ if (theme === 'system') {
2506
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
2507
+ effectiveTheme = prefersDark ? 'dark' : 'light';
2508
+ } else {
2509
+ effectiveTheme = theme;
2510
+ }
2511
+
2512
+ // Apply theme class to document element
2513
+ const root = document.documentElement;
2514
+ root.classList.remove('theme-light', 'theme-dark');
2515
+ root.classList.add(`theme-${effectiveTheme}`);
2516
+ root.style.colorScheme = effectiveTheme;
2517
+ };
2518
+
2519
+ applyTheme(settings.theme);
2520
+
2521
+ if (settings.theme === 'system') {
2522
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
2523
+ const handleChange = () => applyTheme('system');
2524
+ mediaQuery.addEventListener('change', handleChange);
2525
+ return () => mediaQuery.removeEventListener('change', handleChange);
2526
+ }
2527
+ }, [settings.theme]);
2528
+
2529
+ // Request browser notification permissions when enabled
2530
+ useEffect(() => {
2531
+ if (!settings.notifications.desktop) return;
2532
+ if (typeof window === 'undefined' || !('Notification' in window)) return;
2533
+
2534
+ if (Notification.permission === 'granted') return;
2535
+
2536
+ if (Notification.permission === 'denied') {
2537
+ updateSettings((prev) => ({
2538
+ ...prev,
2539
+ notifications: {
2540
+ ...prev.notifications,
2541
+ desktop: false,
2542
+ enabled: prev.notifications.sound || prev.notifications.mentionsOnly,
2543
+ },
2544
+ }));
2545
+ return;
2546
+ }
2547
+
2548
+ Notification.requestPermission().then((permission) => {
2549
+ if (permission !== 'granted') {
2550
+ updateSettings((prev) => ({
2551
+ ...prev,
2552
+ notifications: {
2553
+ ...prev.notifications,
2554
+ desktop: false,
2555
+ enabled: prev.notifications.sound || prev.notifications.mentionsOnly,
2556
+ },
2557
+ }));
2558
+ }
2559
+ }).catch(() => undefined);
2560
+ }, [settings.notifications.desktop, settings.notifications.sound, settings.notifications.mentionsOnly, updateSettings]);
2561
+
2562
+ // Browser notifications and sounds for new messages
2563
+ useEffect(() => {
2564
+ const messages = data?.messages;
2565
+ if (!messages || messages.length === 0) {
2566
+ lastNotifiedMessageIdRef.current = null;
2567
+ return;
2568
+ }
2569
+
2570
+ const latestMessage = messages[messages.length - 1];
2571
+
2572
+ if (!settings.notifications.enabled) {
2573
+ lastNotifiedMessageIdRef.current = latestMessage?.id ?? null;
2574
+ return;
2575
+ }
2576
+
2577
+ if (!lastNotifiedMessageIdRef.current) {
2578
+ lastNotifiedMessageIdRef.current = latestMessage.id;
2579
+ return;
2580
+ }
2581
+
2582
+ const lastNotifiedIndex = messages.findIndex((message) => (
2583
+ message.id === lastNotifiedMessageIdRef.current
2584
+ ));
2585
+
2586
+ if (lastNotifiedIndex === -1) {
2587
+ lastNotifiedMessageIdRef.current = latestMessage.id;
2588
+ return;
2589
+ }
2590
+
2591
+ const newMessages = messages.slice(lastNotifiedIndex + 1);
2592
+ if (newMessages.length === 0) {
2593
+ return;
2594
+ }
2595
+
2596
+ lastNotifiedMessageIdRef.current = latestMessage.id;
2597
+
2598
+ const isFromCurrentUser = (message: Message) =>
2599
+ message.from === 'Dashboard' ||
2600
+ (currentUser && message.from === currentUser.displayName);
2601
+
2602
+ const isMessageInCurrentChannel = (message: Message) => {
2603
+ return message.from === currentChannel || message.to === currentChannel;
2604
+ };
2605
+
2606
+ const shouldNotifyForMessage = (message: Message) => {
2607
+ if (isFromCurrentUser(message)) return false;
2608
+ if (settings.notifications.mentionsOnly && currentUser?.displayName) {
2609
+ if (!message.content.includes(`@${currentUser.displayName}`)) {
2610
+ return false;
2611
+ }
2612
+ }
2613
+ const isActive = typeof document !== 'undefined' ? !document.hidden : false;
2614
+ if (isActive && isMessageInCurrentChannel(message)) return false;
2615
+ return true;
2616
+ };
2617
+
2618
+ let shouldPlaySound = false;
2619
+
2620
+ for (const message of newMessages) {
2621
+ if (!shouldNotifyForMessage(message)) continue;
2622
+
2623
+ if (settings.notifications.desktop && typeof window !== 'undefined' && 'Notification' in window) {
2624
+ if (Notification.permission === 'granted') {
2625
+ const channelLabel = message.to;
2626
+ const body = message.content.split('\n')[0].slice(0, 160);
2627
+ const notification = new Notification(`${message.from} → ${channelLabel}`, { body });
2628
+ notification.onclick = () => {
2629
+ window.focus();
2630
+ setCurrentChannel(message.from);
2631
+ notification.close();
2632
+ };
2633
+ }
2634
+ }
2635
+
2636
+ if (settings.notifications.sound) {
2637
+ shouldPlaySound = true;
2638
+ }
2639
+ }
2640
+
2641
+ if (shouldPlaySound) {
2642
+ playNotificationSound();
2643
+ }
2644
+ }, [data?.messages, settings.notifications, currentChannel, currentUser, setCurrentChannel]);
2645
+
2646
+ // Keyboard shortcuts
2647
+ React.useEffect(() => {
2648
+ const handleKeyDown = (e: KeyboardEvent) => {
2649
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
2650
+ e.preventDefault();
2651
+ setIsCommandPaletteOpen(true);
2652
+ }
2653
+
2654
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 's') {
2655
+ e.preventDefault();
2656
+ handleSpawnClick();
2657
+ }
2658
+
2659
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
2660
+ e.preventDefault();
2661
+ setViewMode('channels');
2662
+ }
2663
+
2664
+ if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
2665
+ e.preventDefault();
2666
+ handleNewConversationClick();
2667
+ }
2668
+
2669
+ if (e.key === 'Escape') {
2670
+ setIsCommandPaletteOpen(false);
2671
+ setIsSpawnModalOpen(false);
2672
+ setIsNewConversationOpen(false);
2673
+ setIsTrajectoryOpen(false);
2674
+ if (isFullSettingsOpen) {
2675
+ setIsFullSettingsOpen(false);
2676
+ urlCloseSettings();
2677
+ }
2678
+ }
2679
+ };
2680
+
2681
+ window.addEventListener('keydown', handleKeyDown);
2682
+ return () => window.removeEventListener('keydown', handleKeyDown);
2683
+ }, [handleSpawnClick, handleNewConversationClick]);
2684
+
2685
+ // Handle billing result routes (success/cancel after Stripe checkout)
2686
+ const pathname = typeof window !== 'undefined' ? window.location.pathname : '';
2687
+ const searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
2688
+
2689
+ if (pathname === '/billing/success') {
2690
+ return (
2691
+ <BillingResult
2692
+ type="success"
2693
+ sessionId={searchParams.get('session_id') || undefined}
2694
+ onClose={() => {
2695
+ window.location.href = '/';
2696
+ }}
2697
+ />
2698
+ );
2699
+ }
2700
+
2701
+ if (pathname === '/billing/canceled') {
2702
+ return (
2703
+ <BillingResult
2704
+ type="canceled"
2705
+ onClose={() => {
2706
+ window.location.href = '/';
2707
+ }}
2708
+ />
2709
+ );
2710
+ }
2711
+
2712
+ return (
2713
+ <WorkspaceProvider wsUrl={wsUrl}>
2714
+ <div className="flex h-screen bg-bg-deep font-sans text-text-primary">
2715
+ {/* Mobile Sidebar Overlay */}
2716
+ <div
2717
+ className={`
2718
+ fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] transition-opacity duration-200
2719
+ md:hidden
2720
+ ${isSidebarOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
2721
+ `}
2722
+ onClick={() => setIsSidebarOpen(false)}
2723
+ />
2724
+
2725
+ {/* Sidebar with Workspace Selector */}
2726
+ <div className={`
2727
+ flex flex-col w-[280px] max-md:w-[85vw] max-md:max-w-[280px] h-screen bg-bg-primary border-r border-border-subtle
2728
+ fixed left-0 top-0 z-[1000] transition-transform duration-200
2729
+ md:relative md:translate-x-0 md:flex-shrink-0
2730
+ ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
2731
+ `}>
2732
+ {/* Workspace Selector */}
2733
+ <div className="p-3 border-b border-sidebar-border">
2734
+ <WorkspaceSelector
2735
+ workspaces={effectiveWorkspaces}
2736
+ activeWorkspaceId={effectiveActiveWorkspaceId ?? undefined}
2737
+ onSelect={handleEffectiveWorkspaceSelect}
2738
+ onAddWorkspace={() => {
2739
+ if (isCloudMode) {
2740
+ // In cloud mode, redirect to app homepage for workspace management
2741
+ // Clear the saved workspace ID and add query param to force showing picker
2742
+ localStorage.removeItem('agentrelay_workspace_id');
2743
+ window.location.href = '/app?select=true';
2744
+ } else {
2745
+ // In local mode, show the add workspace modal
2746
+ setIsAddWorkspaceOpen(true);
2747
+ }
2748
+ }}
2749
+ onWorkspaceSettings={handleWorkspaceSettingsClick}
2750
+ isLoading={effectiveIsLoading}
2751
+ />
2752
+ </div>
2753
+
2754
+ {/* Unified Sidebar - Channels collapsed by default, Agents always visible */}
2755
+ <Sidebar
2756
+ agents={localAgentsForSidebar}
2757
+ bridgeAgents={bridgeAgents}
2758
+ projects={mergedProjects}
2759
+ currentUserName={currentUser?.displayName}
2760
+ humanUnreadCounts={humanUnreadCounts}
2761
+ currentProject={currentProject}
2762
+ selectedAgent={selectedAgent?.name}
2763
+ viewMode={viewMode}
2764
+ isFleetAvailable={isFleetAvailable}
2765
+ isConnected={isConnected || isOrchestratorConnected}
2766
+ isOpen={isSidebarOpen}
2767
+ activeThreads={activeThreads}
2768
+ currentThread={currentThread}
2769
+ totalUnreadThreadCount={totalUnreadThreadCount}
2770
+ channels={channelsList
2771
+ .filter(c => !c.isDm && !c.id.startsWith('dm:'))
2772
+ .map(c => ({
2773
+ id: c.id,
2774
+ name: c.name,
2775
+ unreadCount: c.unreadCount,
2776
+ hasMentions: c.hasMentions,
2777
+ }))}
2778
+ archivedChannels={archivedChannelsList
2779
+ .filter(c => !c.isDm && !c.id.startsWith('dm:'))
2780
+ .map((c) => ({
2781
+ id: c.id,
2782
+ name: c.name,
2783
+ unreadCount: c.unreadCount ?? 0,
2784
+ hasMentions: c.hasMentions,
2785
+ }))}
2786
+ selectedChannelId={selectedChannelId}
2787
+ isActivitySelected={selectedChannelId === ACTIVITY_FEED_ID}
2788
+ activityUnreadCount={0}
2789
+ onActivitySelect={() => {
2790
+ setSelectedChannelId(ACTIVITY_FEED_ID);
2791
+ selectAgent(null);
2792
+ setViewMode('channels');
2793
+ navigateToActivity();
2794
+ }}
2795
+ onChannelSelect={(channel) => {
2796
+ const fullChannel =
2797
+ channelsList.find(c => c.id === channel.id) ||
2798
+ archivedChannelsList.find(c => c.id === channel.id);
2799
+ if (fullChannel) {
2800
+ handleSelectChannel(fullChannel);
2801
+ setViewMode('channels');
2802
+ }
2803
+ }}
2804
+ onCreateChannel={handleCreateChannel}
2805
+ onInviteToChannel={(channel) => {
2806
+ const fullChannel = channelsList.find(c => c.id === channel.id);
2807
+ if (fullChannel) {
2808
+ handleInviteToChannel(fullChannel);
2809
+ }
2810
+ }}
2811
+ onArchiveChannel={(channel) => {
2812
+ const fullChannel = channelsList.find((c) => c.id === channel.id);
2813
+ if (fullChannel) {
2814
+ handleArchiveChannel(fullChannel);
2815
+ }
2816
+ }}
2817
+ onUnarchiveChannel={(channel) => {
2818
+ const fullChannel =
2819
+ archivedChannelsList.find((c) => c.id === channel.id) ||
2820
+ channelsList.find((c) => c.id === channel.id);
2821
+ if (fullChannel) {
2822
+ handleUnarchiveChannel(fullChannel);
2823
+ }
2824
+ }}
2825
+ onAgentSelect={handleAgentSelect}
2826
+ onHumanSelect={handleHumanSelect}
2827
+ onProjectSelect={handleProjectSelect}
2828
+ onViewModeChange={setViewMode}
2829
+ onSpawnClick={handleSpawnClick}
2830
+ onReleaseClick={handleReleaseAgent}
2831
+ onLogsClick={handleLogsClick}
2832
+ onProfileClick={setSelectedAgentProfile}
2833
+ onThreadSelect={setCurrentThread}
2834
+ onClose={() => setIsSidebarOpen(false)}
2835
+ onSettingsClick={handleSettingsClick}
2836
+ onTrajectoryClick={() => setIsTrajectoryOpen(true)}
2837
+ hasActiveTrajectory={trajectoryStatus?.active}
2838
+ onFleetClick={() => setIsFleetViewActive(!isFleetViewActive)}
2839
+ isFleetViewActive={isFleetViewActive}
2840
+ />
2841
+ </div>
2842
+
2843
+ {/* Main Content */}
2844
+ <main className="flex-1 flex flex-col min-w-0 bg-bg-secondary/50 overflow-hidden">
2845
+ {/* Header - fixed on mobile for keyboard-safe positioning, sticky on desktop */}
2846
+ <div className="fixed top-0 left-0 right-0 z-50 md:sticky md:top-0 md:left-auto md:right-auto bg-bg-secondary">
2847
+ <Header
2848
+ currentChannel={currentChannel}
2849
+ selectedAgent={selectedAgent}
2850
+ projects={mergedProjects}
2851
+ currentProject={mergedProjects.find(p => p.id === currentProject) || null}
2852
+ recentProjects={getRecentProjects(mergedProjects)}
2853
+ viewMode={viewMode}
2854
+ selectedChannelName={selectedChannel?.name}
2855
+ onProjectChange={handleProjectSelect}
2856
+ onCommandPaletteOpen={handleCommandPaletteOpen}
2857
+ onSettingsClick={handleSettingsClick}
2858
+ onHistoryClick={handleHistoryClick}
2859
+ onNewConversationClick={handleNewConversationClick}
2860
+ onFleetClick={() => setIsFleetViewActive(!isFleetViewActive)}
2861
+ isFleetViewActive={isFleetViewActive}
2862
+ onTrajectoryClick={() => setIsTrajectoryOpen(true)}
2863
+ hasActiveTrajectory={trajectoryStatus?.active}
2864
+ onMenuClick={() => setIsSidebarOpen(true)}
2865
+ hasUnreadNotifications={hasUnreadMessages}
2866
+ />
2867
+ {/* Usage banner for free tier users */}
2868
+ <UsageBanner onUpgradeClick={handleBillingClick} />
2869
+ </div>
2870
+ {/* Spacer for fixed header on mobile - matches header height (52px) */}
2871
+ <div className="h-[52px] flex-shrink-0 md:hidden" />
2872
+ {/* Online users indicator - outside fixed header so it scrolls with content on mobile */}
2873
+ {currentUser && onlineUsers.length > 0 && (
2874
+ <div className="flex items-center justify-end px-4 py-1 bg-bg-tertiary/80 border-b border-border-subtle flex-shrink-0">
2875
+ <OnlineUsersIndicator
2876
+ onlineUsers={onlineUsers}
2877
+ onUserClick={setSelectedUserProfile}
2878
+ />
2879
+ </div>
2880
+ )}
2881
+
2882
+ {/* Content Area */}
2883
+ <div className="flex-1 flex overflow-hidden min-h-0">
2884
+ {/* Message List */}
2885
+ <div className={`flex-1 min-h-0 overflow-y-auto ${currentThread ? 'hidden md:block md:flex-[2]' : ''}`}>
2886
+ {currentHuman && (
2887
+ <div className="px-4 py-2 border-b border-border-subtle bg-bg-secondary flex flex-col gap-2 sticky top-0 z-10">
2888
+ <div className="text-xs text-text-muted">
2889
+ DM with <span className="font-semibold text-text-primary">{currentHuman.name}</span>. Invite agents:
2890
+ </div>
2891
+ <div className="flex flex-wrap gap-2">
2892
+ {agents
2893
+ .filter((a) => !a.isHuman)
2894
+ .map((agent) => {
2895
+ const isSelected = (dmSelectedAgentsByHuman[currentHuman.name] ?? []).includes(agent.name);
2896
+ return (
2897
+ <button
2898
+ key={agent.name}
2899
+ onClick={() => handleDmAgentToggle(agent.name)}
2900
+ className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
2901
+ isSelected
2902
+ ? 'bg-accent-cyan text-bg-deep'
2903
+ : 'bg-bg-tertiary text-text-secondary hover:bg-bg-tertiary/80'
2904
+ }`}
2905
+ title={agent.name}
2906
+ >
2907
+ {isSelected ? '✓ ' : ''}{agent.name}
2908
+ </button>
2909
+ );
2910
+ })}
2911
+ {agents.filter((a) => !a.isHuman).length === 0 && (
2912
+ <span className="text-xs text-text-muted">No agents available</span>
2913
+ )}
2914
+ </div>
2915
+ </div>
2916
+ )}
2917
+ {wsError && !data && restFallbackFailed ? (
2918
+ <div className="flex flex-col items-center justify-center h-full text-text-muted text-center px-4">
2919
+ <ErrorIcon />
2920
+ <h2 className="m-0 mb-2 font-display text-text-primary">Connection Error</h2>
2921
+ <p className="text-text-secondary">{wsError.message}</p>
2922
+ <button
2923
+ className="mt-6 py-3 px-6 bg-gradient-to-r from-accent-cyan to-[#00b8d9] text-bg-deep font-semibold border-none rounded-xl cursor-pointer transition-all duration-150 hover:shadow-glow-cyan hover:-translate-y-0.5"
2924
+ onClick={() => window.location.reload()}
2925
+ >
2926
+ Retry Connection
2927
+ </button>
2928
+ </div>
2929
+ ) : !data ? (
2930
+ <div className="flex flex-col items-center justify-center h-full text-text-muted text-center">
2931
+ <LoadingSpinner />
2932
+ <p className="font-display text-text-secondary">Connecting to dashboard...</p>
2933
+ </div>
2934
+ ) : isFleetViewActive ? (
2935
+ <div className="p-4 h-full overflow-y-auto">
2936
+ <FleetOverview
2937
+ servers={fleetServers}
2938
+ agents={agents}
2939
+ selectedServerId={selectedServerId}
2940
+ onServerSelect={setSelectedServerId}
2941
+ onServerReconnect={handleServerReconnect}
2942
+ isLoading={!data}
2943
+ />
2944
+ </div>
2945
+ ) : selectedChannelId === ACTIVITY_FEED_ID ? (
2946
+ <ActivityFeed
2947
+ events={activityEvents}
2948
+ maxEvents={100}
2949
+ />
2950
+ ) : viewMode === 'channels' && selectedChannel ? (
2951
+ <ChannelViewV1
2952
+ channel={selectedChannel}
2953
+ messages={effectiveChannelMessages}
2954
+ currentUser={currentUser?.displayName || 'Anonymous'}
2955
+ isLoadingMore={false}
2956
+ hasMoreMessages={hasMoreMessages && !!effectiveActiveWorkspaceId}
2957
+ mentionSuggestions={agents.map(a => a.name)}
2958
+ unreadState={channelUnreadState}
2959
+ onSendMessage={handleSendChannelMessage}
2960
+ onLoadMore={handleLoadMoreMessages}
2961
+ onThreadClick={(messageId) => setCurrentThread(messageId)}
2962
+ onShowMembers={handleShowMembers}
2963
+ onMemberClick={handleChannelMemberClick}
2964
+ />
2965
+ ) : viewMode === 'channels' ? (
2966
+ <div className="flex flex-col items-center justify-center h-full text-text-muted text-center px-4">
2967
+ <HashIconLarge />
2968
+ <h2 className="m-0 mb-2 font-display text-text-primary">Select a channel</h2>
2969
+ <p className="text-text-secondary">Choose a channel from the sidebar to start messaging</p>
2970
+ </div>
2971
+ ) : (
2972
+ <MessageList
2973
+ messages={dedupedVisibleMessages}
2974
+ currentChannel={currentChannel}
2975
+ currentThread={currentThread}
2976
+ onThreadClick={(messageId) => setCurrentThread(messageId)}
2977
+ highlightedMessageId={currentThread ?? undefined}
2978
+ agents={combinedAgents}
2979
+ currentUser={currentUser}
2980
+ skipChannelFilter={currentHuman !== null}
2981
+ showTimestamps={settings.display.showTimestamps}
2982
+ autoScrollDefault={settings.messages.autoScroll}
2983
+ compactMode={settings.display.compactMode}
2984
+ onAgentClick={setSelectedAgentProfile}
2985
+ onUserClick={setSelectedUserProfile}
2986
+ onLogsClick={handleLogsClick}
2987
+ onlineUsers={onlineUsers}
2988
+ onReaction={enableReactions ? handleReaction : undefined}
2989
+ />
2990
+ )}
2991
+ </div>
2992
+
2993
+ {/* Thread Panel */}
2994
+ {currentThread && (() => {
2995
+ const isChannelView = viewMode === 'channels';
2996
+
2997
+ // Helper to convert ChannelMessage to Message format for ThreadPanel
2998
+ const convertChannelMessage = (cm: ChannelApiMessage): Message => ({
2999
+ id: cm.id,
3000
+ from: cm.from,
3001
+ to: cm.channelId,
3002
+ content: cm.content,
3003
+ timestamp: cm.timestamp,
3004
+ thread: cm.threadId,
3005
+ isRead: cm.isRead,
3006
+ replyCount: cm.threadSummary?.replyCount,
3007
+ threadSummary: cm.threadSummary,
3008
+ });
3009
+
3010
+ let originalMessage: Message | null = null;
3011
+ let replies: Message[] = [];
3012
+ let isTopicThread = false;
3013
+ let threadIsLoading = false;
3014
+ let threadHasMore = false;
3015
+ let threadLoadMore: (() => void) | undefined;
3016
+
3017
+ if (isChannelView) {
3018
+ // Channel view: use inline filtering (useThread doesn't handle ChannelApiMessage)
3019
+ const channelMsg = effectiveChannelMessages.find((m) => m.id === currentThread);
3020
+ if (channelMsg) {
3021
+ originalMessage = convertChannelMessage(channelMsg);
3022
+ } else {
3023
+ isTopicThread = true;
3024
+ const threadMsgs = effectiveChannelMessages
3025
+ .filter((m) => m.threadId === currentThread)
3026
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
3027
+ if (threadMsgs[0]) {
3028
+ originalMessage = convertChannelMessage(threadMsgs[0]);
3029
+ }
3030
+ }
3031
+ replies = effectiveChannelMessages
3032
+ .filter((m) => m.threadId === currentThread)
3033
+ .map(convertChannelMessage);
3034
+ } else {
3035
+ // Non-channel view: use the useThread hook (API-backed with fallback)
3036
+ originalMessage = thread.parentMessage;
3037
+ replies = thread.replies;
3038
+ isTopicThread = !originalMessage;
3039
+ threadIsLoading = thread.isLoading;
3040
+ threadHasMore = thread.hasMore;
3041
+ threadLoadMore = thread.loadMore;
3042
+ }
3043
+
3044
+ return (
3045
+ <div className="w-full md:w-[400px] md:min-w-[320px] md:max-w-[500px] flex-shrink-0 h-full overflow-hidden">
3046
+ <ThreadPanel
3047
+ originalMessage={originalMessage}
3048
+ replies={replies}
3049
+ onClose={() => setCurrentThread(null)}
3050
+ showTimestamps={settings.display.showTimestamps}
3051
+ isLoading={threadIsLoading}
3052
+ hasMore={threadHasMore}
3053
+ onLoadMore={threadLoadMore}
3054
+ onReply={async (content) => {
3055
+ if (isChannelView && selectedChannel) {
3056
+ await handleSendChannelMessage(content, currentThread);
3057
+ return true;
3058
+ }
3059
+ let recipient = '*';
3060
+ if (!isTopicThread && originalMessage) {
3061
+ const isFromCurrentUser = originalMessage.from === 'Dashboard' ||
3062
+ (currentUser && originalMessage.from === currentUser.displayName);
3063
+ recipient = isFromCurrentUser
3064
+ ? originalMessage.to
3065
+ : originalMessage.from;
3066
+ }
3067
+ return sendMessage(recipient, content, currentThread);
3068
+ }}
3069
+ isSending={isSending}
3070
+ currentUser={currentUser}
3071
+ />
3072
+ </div>
3073
+ );
3074
+ })()}
3075
+ </div>
3076
+
3077
+ {/* Typing Indicator */}
3078
+ {typingUsers.length > 0 && (
3079
+ <div className="px-4 bg-bg-tertiary border-t border-border-subtle">
3080
+ <TypingIndicator typingUsers={typingUsers} />
3081
+ </div>
3082
+ )}
3083
+
3084
+ {/* Message Composer - hide in channels mode (ChannelViewV1 has its own input) */}
3085
+ {viewMode !== 'channels' && (
3086
+ <div className="p-2 sm:p-4 bg-bg-tertiary border-t border-border-subtle">
3087
+ <MessageComposer
3088
+ agents={agents}
3089
+ humanUsers={humanUsers}
3090
+ onSend={handleMainComposerSend}
3091
+ onTyping={sendTyping}
3092
+ isSending={isSending}
3093
+ error={sendError}
3094
+ insertMention={pendingMention}
3095
+ onMentionInserted={() => setPendingMention(undefined)}
3096
+ enableFileAutocomplete
3097
+ placeholder={`Message @${currentChannel}...`}
3098
+ />
3099
+ </div>
3100
+ )}
3101
+ </main>
3102
+
3103
+ {/* Command Palette */}
3104
+ <CommandPalette
3105
+ isOpen={isCommandPaletteOpen}
3106
+ onClose={handleCommandPaletteClose}
3107
+ agents={agents}
3108
+ projects={projects}
3109
+ currentProject={currentProject}
3110
+ onAgentSelect={handleAgentSelect}
3111
+ onProjectSelect={handleProjectSelect}
3112
+ onSpawnClick={handleSpawnClick}
3113
+ onTaskCreate={handleTaskCreate}
3114
+ onGeneralClick={() => {
3115
+ selectAgent(null);
3116
+ setSelectedChannelId('#general');
3117
+ setViewMode('channels');
3118
+ }}
3119
+ customCommands={[...dmInviteCommands, ...channelCommands]}
3120
+ />
3121
+
3122
+ {/* Spawn Modal */}
3123
+ <SpawnModal
3124
+ isOpen={isSpawnModalOpen}
3125
+ onClose={() => setIsSpawnModalOpen(false)}
3126
+ onSpawn={handleSpawn}
3127
+ existingAgents={agents.map((a) => a.name)}
3128
+ isSpawning={isSpawning}
3129
+ error={spawnError}
3130
+ isCloudMode={isCloudMode}
3131
+ workspaceId={effectiveActiveWorkspaceId ?? undefined}
3132
+ agentDefaults={settings.agentDefaults}
3133
+ repos={workspaceRepos}
3134
+ activeRepoId={workspaceRepos.find(r => r.id === currentProject)?.id ?? workspaceRepos[0]?.id}
3135
+ />
3136
+
3137
+ {/* Add Workspace Modal */}
3138
+ <AddWorkspaceModal
3139
+ isOpen={isAddWorkspaceOpen}
3140
+ onClose={() => {
3141
+ setIsAddWorkspaceOpen(false);
3142
+ setAddWorkspaceError(null);
3143
+ }}
3144
+ onAdd={handleAddWorkspace}
3145
+ isAdding={isAddingWorkspace}
3146
+ error={addWorkspaceError}
3147
+ />
3148
+
3149
+ {/* Create Channel Modal */}
3150
+ <CreateChannelModal
3151
+ isOpen={isCreateChannelOpen}
3152
+ onClose={() => setIsCreateChannelOpen(false)}
3153
+ onCreate={handleCreateChannelSubmit}
3154
+ isLoading={isCreatingChannel}
3155
+ existingChannels={channelsList.map(c => c.name)}
3156
+ availableMembers={agents.map(a => a.name)}
3157
+ workspaceId={effectiveActiveWorkspaceId ?? undefined}
3158
+ />
3159
+
3160
+ {/* Invite to Channel Modal */}
3161
+ <InviteToChannelModal
3162
+ isOpen={isInviteChannelOpen}
3163
+ channelName={inviteChannelTarget?.name || ''}
3164
+ onClose={() => {
3165
+ setIsInviteChannelOpen(false);
3166
+ setInviteChannelTarget(null);
3167
+ }}
3168
+ onInvite={handleInviteSubmit}
3169
+ isLoading={isInvitingToChannel}
3170
+ availableMembers={agents.map(a => a.name)}
3171
+ />
3172
+
3173
+ {/* Member Management Panel */}
3174
+ {selectedChannel && (
3175
+ <MemberManagementPanel
3176
+ channel={selectedChannel}
3177
+ members={channelMembers}
3178
+ isOpen={showMemberPanel}
3179
+ onClose={() => setShowMemberPanel(false)}
3180
+ onAddMember={handleAddMember}
3181
+ onRemoveMember={handleRemoveMember}
3182
+ onUpdateRole={() => {}}
3183
+ currentUserId={currentUser?.displayName}
3184
+ availableAgents={agents.map(a => ({ name: a.name }))}
3185
+ workspaceId={effectiveActiveWorkspaceId ?? undefined}
3186
+ />
3187
+ )}
3188
+
3189
+ {/* Conversation History */}
3190
+ <ConversationHistory
3191
+ isOpen={isHistoryOpen}
3192
+ onClose={() => setIsHistoryOpen(false)}
3193
+ />
3194
+
3195
+ {/* New Conversation Modal */}
3196
+ <NewConversationModal
3197
+ isOpen={isNewConversationOpen}
3198
+ onClose={() => setIsNewConversationOpen(false)}
3199
+ onSend={handleNewConversationSend}
3200
+ agents={agents}
3201
+ isSending={isSending}
3202
+ error={sendError}
3203
+ />
3204
+
3205
+ {/* Log Viewer Panel */}
3206
+ {logViewerAgent && (
3207
+ <LogViewerPanel
3208
+ agent={logViewerAgent}
3209
+ isOpen={true}
3210
+ onClose={() => setLogViewerAgent(null)}
3211
+ availableAgents={agents}
3212
+ onAgentChange={setLogViewerAgent}
3213
+ />
3214
+ )}
3215
+
3216
+ {/* Trajectory Panel - Fullscreen slide-over */}
3217
+ {isTrajectoryOpen && (
3218
+ <div
3219
+ className="fixed inset-0 z-50 flex bg-black/50 backdrop-blur-sm"
3220
+ onClick={() => setIsTrajectoryOpen(false)}
3221
+ >
3222
+ <div
3223
+ className="ml-auto w-full max-w-3xl h-full bg-bg-primary shadow-2xl animate-in slide-in-from-right duration-300 flex flex-col"
3224
+ onClick={(e) => e.stopPropagation()}
3225
+ >
3226
+ {/* Header */}
3227
+ <div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-bg-secondary">
3228
+ <div className="flex items-center gap-3">
3229
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-accent-cyan/20 flex items-center justify-center border border-blue-500/30">
3230
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-blue-500">
3231
+ <path d="M3 12h4l3 9 4-18 3 9h4" strokeLinecap="round" strokeLinejoin="round" />
3232
+ </svg>
3233
+ </div>
3234
+ <div>
3235
+ <h2 className="text-lg font-semibold text-text-primary m-0">Trajectory Viewer</h2>
3236
+ <p className="text-xs text-text-muted m-0">
3237
+ {trajectoryStatus?.active ? `Active: ${trajectoryStatus.task || 'Working...'}` : 'Browse past trajectories'}
3238
+ </p>
3239
+ </div>
3240
+ </div>
3241
+ <button
3242
+ onClick={() => setIsTrajectoryOpen(false)}
3243
+ className="w-10 h-10 rounded-lg bg-bg-tertiary border border-border-subtle flex items-center justify-center text-text-muted hover:text-text-primary hover:bg-bg-hover hover:border-blue-500/50 transition-all"
3244
+ title="Close (Esc)"
3245
+ >
3246
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
3247
+ <path d="M18 6L6 18M6 6l12 12" />
3248
+ </svg>
3249
+ </button>
3250
+ </div>
3251
+
3252
+ {/* Content */}
3253
+ <div className="flex-1 overflow-hidden p-6">
3254
+ <TrajectoryViewer
3255
+ agentName={selectedTrajectoryTitle?.slice(0, 30) || trajectoryStatus?.task?.slice(0, 30) || 'Trajectories'}
3256
+ steps={trajectorySteps}
3257
+ history={trajectoryHistory}
3258
+ selectedTrajectoryId={selectedTrajectoryId}
3259
+ onSelectTrajectory={selectTrajectory}
3260
+ isLoading={isTrajectoryLoading}
3261
+ />
3262
+ </div>
3263
+ </div>
3264
+ </div>
3265
+ )}
3266
+
3267
+
3268
+ {/* Decision Queue Panel */}
3269
+ {isDecisionQueueOpen && (
3270
+ <div className="fixed left-4 bottom-4 w-[400px] max-h-[500px] z-50 shadow-modal">
3271
+ <div className="relative">
3272
+ <button
3273
+ onClick={() => setIsDecisionQueueOpen(false)}
3274
+ className="absolute -top-2 -right-2 w-6 h-6 bg-bg-elevated border border-border rounded-full flex items-center justify-center text-text-muted hover:text-text-primary hover:bg-bg-hover z-10"
3275
+ title="Close decisions"
3276
+ >
3277
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
3278
+ <path d="M18 6L6 18M6 6l12 12" />
3279
+ </svg>
3280
+ </button>
3281
+ <DecisionQueue
3282
+ decisions={decisions}
3283
+ onApprove={handleDecisionApprove}
3284
+ onReject={handleDecisionReject}
3285
+ onDismiss={handleDecisionDismiss}
3286
+ isProcessing={decisionProcessing}
3287
+ />
3288
+ </div>
3289
+ </div>
3290
+ )}
3291
+
3292
+ {/* Decision Queue Toggle Button (bottom-left when panel is closed) */}
3293
+ {!isDecisionQueueOpen && decisions.length > 0 && (
3294
+ <button
3295
+ onClick={() => setIsDecisionQueueOpen(true)}
3296
+ className="fixed left-4 bottom-4 w-12 h-12 bg-warning text-bg-deep rounded-full shadow-[0_0_20px_rgba(255,107,53,0.4)] flex items-center justify-center hover:scale-105 transition-transform z-50"
3297
+ title={`${decisions.length} pending decision${decisions.length > 1 ? 's' : ''}`}
3298
+ >
3299
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
3300
+ <circle cx="12" cy="12" r="10" />
3301
+ <line x1="12" y1="8" x2="12" y2="12" />
3302
+ <line x1="12" y1="16" x2="12.01" y2="16" />
3303
+ </svg>
3304
+ {decisions.length > 0 && (
3305
+ <span className="absolute -top-1 -right-1 w-5 h-5 bg-error text-white text-[10px] font-bold rounded-full flex items-center justify-center">
3306
+ {decisions.length}
3307
+ </span>
3308
+ )}
3309
+ </button>
3310
+ )}
3311
+
3312
+ {/* User Profile Panel */}
3313
+ <UserProfilePanel
3314
+ user={selectedUserProfile}
3315
+ onClose={() => setSelectedUserProfile(null)}
3316
+ onMention={(username) => {
3317
+ // Set pending mention to trigger insertion in MessageComposer
3318
+ setPendingMention(username);
3319
+ setSelectedUserProfile(null);
3320
+ }}
3321
+ onSendMessage={(user) => {
3322
+ setCurrentChannel(user.username);
3323
+ markDmSeen(user.username);
3324
+ setSelectedUserProfile(null);
3325
+ }}
3326
+ />
3327
+
3328
+ {/* Agent Profile Panel */}
3329
+ <AgentProfilePanel
3330
+ agent={selectedAgentProfile}
3331
+ onClose={() => setSelectedAgentProfile(null)}
3332
+ onMessage={(agent) => {
3333
+ selectAgent(agent.name);
3334
+ setCurrentChannel(agent.name);
3335
+ setSelectedAgentProfile(null);
3336
+ }}
3337
+ onLogs={handleLogsClick}
3338
+ onRelease={handleReleaseAgent}
3339
+ summary={selectedAgentProfile ? agentSummariesMap.get(selectedAgentProfile.name.toLowerCase()) : null}
3340
+ />
3341
+
3342
+ {/* Coordinator Panel */}
3343
+ <CoordinatorPanel
3344
+ isOpen={isCoordinatorOpen}
3345
+ onClose={() => setIsCoordinatorOpen(false)}
3346
+ projects={mergedProjects}
3347
+ isCloudMode={!!currentUser}
3348
+ hasArchitect={bridgeAgents.some(a => a.name.toLowerCase() === 'architect')}
3349
+ onArchitectSpawned={() => {
3350
+ // Architect will appear via WebSocket update
3351
+ setIsCoordinatorOpen(false);
3352
+ }}
3353
+ />
3354
+
3355
+ {/* Full Settings Page */}
3356
+ {isFullSettingsOpen && (
3357
+ <SettingsPage
3358
+ currentUserId={cloudSession?.user?.id}
3359
+ initialTab={settingsInitialTab}
3360
+ onClose={() => {
3361
+ setIsFullSettingsOpen(false);
3362
+ urlCloseSettings();
3363
+ }}
3364
+ settings={settings}
3365
+ onUpdateSettings={updateSettings}
3366
+ activeWorkspaceId={effectiveActiveWorkspaceId}
3367
+ onReposChanged={refetchWorkspaceRepos}
3368
+ />
3369
+ )}
3370
+
3371
+ {/* Toast Notifications */}
3372
+ <NotificationToast
3373
+ toasts={toasts}
3374
+ onDismiss={dismissToast}
3375
+ position="top-right"
3376
+ />
3377
+ </div>
3378
+ </WorkspaceProvider>
3379
+ );
3380
+ }
3381
+
3382
+ function LoadingSpinner() {
3383
+ return (
3384
+ <svg className="animate-spin mb-4 text-accent-cyan" width="28" height="28" viewBox="0 0 24 24">
3385
+ <circle
3386
+ cx="12"
3387
+ cy="12"
3388
+ r="10"
3389
+ stroke="currentColor"
3390
+ strokeWidth="2"
3391
+ fill="none"
3392
+ strokeDasharray="32"
3393
+ strokeLinecap="round"
3394
+ />
3395
+ </svg>
3396
+ );
3397
+ }
3398
+
3399
+ function ErrorIcon() {
3400
+ return (
3401
+ <svg className="text-error mb-4" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
3402
+ <circle cx="12" cy="12" r="10" />
3403
+ <line x1="12" y1="8" x2="12" y2="12" />
3404
+ <line x1="12" y1="16" x2="12.01" y2="16" />
3405
+ </svg>
3406
+ );
3407
+ }
3408
+
3409
+ function HashIconLarge() {
3410
+ return (
3411
+ <svg className="text-text-muted mb-4" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
3412
+ <line x1="4" y1="9" x2="20" y2="9" />
3413
+ <line x1="4" y1="15" x2="20" y2="15" />
3414
+ <line x1="10" y1="3" x2="8" y2="21" />
3415
+ <line x1="16" y1="3" x2="14" y2="21" />
3416
+ </svg>
3417
+ );
3418
+ }
3419
+
3420
+ /**
3421
+ * Legacy CSS styles export - kept for backwards compatibility
3422
+ * @deprecated Use Tailwind classes directly instead
3423
+ */
3424
+ export const appStyles = '';