@agent-relay/dashboard 2.0.82 → 2.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/1028-da5d75e35d1420f1.js +1 -0
  3. package/out/_next/static/chunks/1528-78b17000a7e10bc6.js +2 -0
  4. package/out/_next/static/chunks/1695-4a5d33ba715e09b4.js +1 -0
  5. package/out/_next/static/chunks/1705-36c2180d00a4a569.js +1 -0
  6. package/out/_next/static/chunks/1dd3208c-e1f87c7b3dc1a820.js +1 -0
  7. package/out/_next/static/chunks/3663-47290254b8f6f5dd.js +1 -0
  8. package/out/_next/static/chunks/3677-4b225baf4801d9b9.js +73 -0
  9. package/out/_next/static/chunks/5118-7e8ada2df38eef07.js +1 -0
  10. package/out/_next/static/chunks/5888-15cbe97c90ed5fae.js +1 -0
  11. package/out/_next/static/chunks/6773-a45343a98df3abb5.js +1 -0
  12. package/out/_next/static/chunks/6940-b824612b605e79b3.js +9 -0
  13. package/out/_next/static/chunks/7894-f4a15249082a680d.js +1 -0
  14. package/out/_next/static/chunks/9175-b3617c1e5cbfed0e.js +1 -0
  15. package/out/_next/static/chunks/9372-1a804b8d08c7a236.js +1 -0
  16. package/out/_next/static/chunks/{ab6c8a12-0a58072fbb505134.js → ab6c8a12-91438a812d94ecf0.js} +1 -1
  17. package/out/_next/static/chunks/app/_not-found/page-8e8842f82d204726.js +1 -0
  18. package/out/_next/static/chunks/app/about/page-b78577a7da8fa459.js +1 -0
  19. package/out/_next/static/chunks/app/app/[[...slug]]/page-3dffd65b6344f53e.js +1 -0
  20. package/out/_next/static/chunks/app/app/onboarding/page-b89be9aa6264a5e1.js +1 -0
  21. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-fbd00893ef69e499.js +1 -0
  22. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-de2ea13649d0b6d3.js +1 -0
  23. package/out/_next/static/chunks/app/blog/page-a08e263c57a156fa.js +1 -0
  24. package/out/_next/static/chunks/app/careers/page-02228e1d6969b232.js +1 -0
  25. package/out/_next/static/chunks/app/changelog/page-1b5c1d79efc6e53a.js +1 -0
  26. package/out/_next/static/chunks/app/cloud/link/page-99654edffffb3af2.js +1 -0
  27. package/out/_next/static/chunks/app/complete-profile/page-59d146e5ddeafc5c.js +1 -0
  28. package/out/_next/static/chunks/app/connect-repos/page-995e16a976a6632c.js +1 -0
  29. package/out/_next/static/chunks/app/contact/page-273396a5ad57bcee.js +1 -0
  30. package/out/_next/static/chunks/app/dev/cli-tools/page-a71b80dcb2d5fc8d.js +1 -0
  31. package/out/_next/static/chunks/app/dev/log-viewer/page-46a6151ae1be0796.js +1 -0
  32. package/out/_next/static/chunks/app/docs/page-7c7cb603b24b7c40.js +1 -0
  33. package/out/_next/static/chunks/app/history/page-0c5cab1dab4e8886.js +1 -0
  34. package/out/_next/static/chunks/app/layout-96d72ba8ef8a43a0.js +1 -0
  35. package/out/_next/static/chunks/app/login/page-0ccbab34213df842.js +1 -0
  36. package/out/_next/static/chunks/app/metrics/page-8616272aeab9c8b0.js +1 -0
  37. package/out/_next/static/chunks/app/page-09ce10603ad9a251.js +1 -0
  38. package/out/_next/static/chunks/app/pricing/page-91c975079120c941.js +1 -0
  39. package/out/_next/static/chunks/app/privacy/{page-c21d51ac2dee3a88.js → page-a49ab271cc686644.js} +1 -1
  40. package/out/_next/static/chunks/app/providers/{page-59114505f4353512.js → page-d775d6eb5bc29e96.js} +1 -1
  41. package/out/_next/static/chunks/app/providers/setup/[provider]/page-ec4ef3cd80de807e.js +1 -0
  42. package/out/_next/static/chunks/app/security/page-d9da9bd9191e8f95.js +1 -0
  43. package/out/_next/static/chunks/app/signup/page-930eca0bf5fd299d.js +1 -0
  44. package/out/_next/static/chunks/app/terms/page-3e4827620b98613c.js +1 -0
  45. package/out/_next/static/chunks/framework-648e1ae7da590300.js +1 -0
  46. package/out/_next/static/chunks/{main-acb1b24265295d6a.js → main-2b1990080c292d92.js} +1 -1
  47. package/out/_next/static/chunks/main-app-9f6b7ff9e754a8f5.js +1 -0
  48. package/out/_next/static/chunks/pages/_app-a077b72e02273ab1.js +1 -0
  49. package/out/_next/static/chunks/pages/_error-84001666436a04e4.js +1 -0
  50. package/out/_next/static/chunks/{webpack-dd93b81e2659669c.js → webpack-7586035f1585f2db.js} +1 -1
  51. package/out/_next/static/css/eb9fc69d1e3d2bed.css +1 -0
  52. package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_buildManifest.js +1 -1
  53. package/out/about.html +2 -2
  54. package/out/about.txt +2 -2
  55. package/out/app/onboarding.html +1 -1
  56. package/out/app/onboarding.txt +2 -2
  57. package/out/app.html +1 -1
  58. package/out/app.txt +2 -2
  59. package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +3 -3
  60. package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
  61. package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
  62. package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
  63. package/out/blog.html +2 -2
  64. package/out/blog.txt +1 -1
  65. package/out/careers.html +2 -2
  66. package/out/careers.txt +2 -2
  67. package/out/changelog.html +2 -2
  68. package/out/changelog.txt +2 -2
  69. package/out/cloud/link.html +1 -1
  70. package/out/cloud/link.txt +2 -2
  71. package/out/complete-profile.html +2 -2
  72. package/out/complete-profile.txt +2 -2
  73. package/out/connect-repos.html +1 -1
  74. package/out/connect-repos.txt +2 -2
  75. package/out/contact.html +2 -2
  76. package/out/contact.txt +2 -2
  77. package/out/dev/cli-tools.html +1 -0
  78. package/out/dev/cli-tools.txt +7 -0
  79. package/out/dev/log-viewer.html +23 -0
  80. package/out/dev/log-viewer.txt +7 -0
  81. package/out/docs.html +2 -2
  82. package/out/docs.txt +2 -2
  83. package/out/history.html +1 -1
  84. package/out/history.txt +2 -2
  85. package/out/index.html +1 -1
  86. package/out/index.txt +2 -2
  87. package/out/login.html +2 -2
  88. package/out/login.txt +2 -2
  89. package/out/metrics.html +1 -1
  90. package/out/metrics.txt +2 -2
  91. package/out/pricing.html +2 -2
  92. package/out/pricing.txt +2 -2
  93. package/out/privacy.html +2 -2
  94. package/out/privacy.txt +2 -2
  95. package/out/providers/setup/claude.html +1 -1
  96. package/out/providers/setup/claude.txt +2 -2
  97. package/out/providers/setup/codex.html +1 -1
  98. package/out/providers/setup/codex.txt +2 -2
  99. package/out/providers/setup/cursor.html +1 -1
  100. package/out/providers/setup/cursor.txt +2 -2
  101. package/out/providers.html +1 -1
  102. package/out/providers.txt +2 -2
  103. package/out/security.html +2 -2
  104. package/out/security.txt +2 -2
  105. package/out/signup.html +2 -2
  106. package/out/signup.txt +2 -2
  107. package/out/terms.html +2 -2
  108. package/out/terms.txt +2 -2
  109. package/package.json +5 -1
  110. package/src/adapters/DashboardConfigProvider.tsx +56 -0
  111. package/src/adapters/cloudFetchAdapter.ts +278 -0
  112. package/src/adapters/index.ts +3 -0
  113. package/src/adapters/types.ts +508 -0
  114. package/src/app/app/[[...slug]]/DashboardPageClient.tsx +67 -18
  115. package/src/app/app/onboarding/page.tsx +870 -170
  116. package/src/app/cloud/link/page.tsx +14 -6
  117. package/src/app/connect-repos/page.tsx +9 -3
  118. package/src/app/dev/cli-tools/page.tsx +130 -0
  119. package/src/app/dev/log-viewer/MockLogViewer.tsx +132 -0
  120. package/src/app/dev/log-viewer/fixtures.ts +110 -0
  121. package/src/app/dev/log-viewer/page.tsx +288 -0
  122. package/src/app/history/page.tsx +28 -12
  123. package/src/app/page.tsx +1 -1
  124. package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +209 -59
  125. package/src/components/AgentCard.tsx +4 -4
  126. package/src/components/AgentLogPreview.tsx +2 -38
  127. package/src/components/App.tsx +441 -2624
  128. package/src/components/CliToolHarness.test.tsx +83 -0
  129. package/src/components/CliToolHarness.tsx +292 -0
  130. package/src/components/CoordinatorPanel.tsx +13 -6
  131. package/src/components/LogViewer.tsx +2 -42
  132. package/src/components/ProviderAuthFlow.tsx +201 -81
  133. package/src/components/ProvisioningProgress.tsx +1 -1
  134. package/src/components/ReactionChips.tsx +2 -1
  135. package/src/components/SpawnModal.test.tsx +51 -18
  136. package/src/components/SpawnModal.tsx +175 -207
  137. package/src/components/TerminalProviderSetup.tsx +1 -1
  138. package/src/components/ThreadPanel.tsx +2 -0
  139. package/src/components/WorkspaceContext.tsx +7 -19
  140. package/src/components/XTermLogViewer.tsx +190 -27
  141. package/src/components/channels/ChannelMessageList.tsx +94 -4
  142. package/src/components/channels/ChannelViewV1.tsx +35 -11
  143. package/src/components/channels/api.ts +21 -20
  144. package/src/components/channels/types.ts +16 -0
  145. package/src/components/hooks/index.ts +0 -19
  146. package/src/components/hooks/useMessages.test.ts +80 -0
  147. package/src/components/hooks/useMessages.ts +13 -4
  148. package/src/components/hooks/useOrchestrator.ts +1 -1
  149. package/src/components/hooks/usePresence.ts +45 -6
  150. package/src/components/hooks/useThread.ts +83 -46
  151. package/src/components/hooks/useTrajectory.ts +62 -5
  152. package/src/components/hooks/useWebSocket.test.ts +358 -0
  153. package/src/components/hooks/useWebSocket.ts +243 -5
  154. package/src/components/index.ts +2 -14
  155. package/src/components/layout/Header.tsx +9 -15
  156. package/src/components/layout/Sidebar.tsx +1 -8
  157. package/src/components/settings/SettingsPage.tsx +108 -47
  158. package/src/components/settings/index.ts +0 -3
  159. package/src/landing/blogData.ts +1 -1
  160. package/src/lib/agent-merge.test.ts +2 -2
  161. package/src/lib/api.ts +8 -38
  162. package/src/lib/identity.test.ts +139 -0
  163. package/src/lib/identity.ts +48 -0
  164. package/src/lib/relaycastMessageAdapters.test.ts +182 -0
  165. package/src/lib/relaycastMessageAdapters.ts +105 -0
  166. package/src/lib/sanitize-logs.test.ts +227 -0
  167. package/src/lib/sanitize-logs.ts +202 -0
  168. package/src/providers/AgentProvider.tsx +799 -0
  169. package/src/providers/ChannelProvider.tsx +528 -0
  170. package/src/providers/CloudWorkspaceProvider.tsx +402 -0
  171. package/src/providers/MessageProvider.tsx +875 -0
  172. package/src/providers/RelayConfigProvider.tsx +94 -0
  173. package/src/providers/SendProvider.tsx +497 -0
  174. package/src/providers/SettingsProvider.tsx +247 -0
  175. package/src/providers/index.ts +26 -0
  176. package/src/types/index.ts +10 -10
  177. package/out/_next/static/chunks/11-9a2993a37266dcb3.js +0 -9
  178. package/out/_next/static/chunks/118-ae2b650136a5a5fc.js +0 -1
  179. package/out/_next/static/chunks/1dd3208c-40ab0fc0f60392b8.js +0 -1
  180. package/out/_next/static/chunks/202-fc0763dd7488e58f.js +0 -1
  181. package/out/_next/static/chunks/259-83b77fa1b91ba5aa.js +0 -1
  182. package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +0 -1
  183. package/out/_next/static/chunks/528-f5f676996d613c25.js +0 -2
  184. package/out/_next/static/chunks/663-ddb04081febc3678.js +0 -1
  185. package/out/_next/static/chunks/687-88b6b139a6bb0e2e.js +0 -1
  186. package/out/_next/static/chunks/695-51d25b1988644374.js +0 -1
  187. package/out/_next/static/chunks/773-54a2641043c81e55.js +0 -1
  188. package/out/_next/static/chunks/app/_not-found/page-6da9b72091e5b511.js +0 -1
  189. package/out/_next/static/chunks/app/about/page-fff7c6457683f243.js +0 -1
  190. package/out/_next/static/chunks/app/app/[[...slug]]/page-f7eca1b66fb4249b.js +0 -1
  191. package/out/_next/static/chunks/app/app/onboarding/page-129abc5da2e67971.js +0 -1
  192. package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-5d5f28fd126b692f.js +0 -1
  193. package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-b194f207fbd91862.js +0 -1
  194. package/out/_next/static/chunks/app/blog/page-b9bd9d8703fca76a.js +0 -1
  195. package/out/_next/static/chunks/app/careers/page-a4bd8d5f4de8f4eb.js +0 -1
  196. package/out/_next/static/chunks/app/changelog/page-9a1f6ad1743d63c5.js +0 -1
  197. package/out/_next/static/chunks/app/cloud/link/page-0844c5699b027c3b.js +0 -1
  198. package/out/_next/static/chunks/app/complete-profile/page-39ed5a67916beb87.js +0 -1
  199. package/out/_next/static/chunks/app/connect-repos/page-297eddee0c39f2a3.js +0 -1
  200. package/out/_next/static/chunks/app/contact/page-3c1dd8690217fade.js +0 -1
  201. package/out/_next/static/chunks/app/docs/page-1875e981f2c3fd13.js +0 -1
  202. package/out/_next/static/chunks/app/history/page-2d5c5695c9e8b40c.js +0 -1
  203. package/out/_next/static/chunks/app/layout-0a4b99656da25511.js +0 -1
  204. package/out/_next/static/chunks/app/login/page-f69c076f5a6fc520.js +0 -1
  205. package/out/_next/static/chunks/app/metrics/page-bebbee055669a17e.js +0 -1
  206. package/out/_next/static/chunks/app/page-0ee604f7070d14c0.js +0 -1
  207. package/out/_next/static/chunks/app/pricing/page-eeae7d594af333b6.js +0 -1
  208. package/out/_next/static/chunks/app/providers/setup/[provider]/page-daf9b3e05e77ae19.js +0 -1
  209. package/out/_next/static/chunks/app/security/page-cd562730fe84a0a2.js +0 -1
  210. package/out/_next/static/chunks/app/signup/page-c242ca08101a84ff.js +0 -1
  211. package/out/_next/static/chunks/app/terms/page-c7001720e7941dc6.js +0 -1
  212. package/out/_next/static/chunks/framework-3664cab31236a9fa.js +0 -1
  213. package/out/_next/static/chunks/main-app-7f73a939a312a228.js +0 -1
  214. package/out/_next/static/chunks/pages/_app-10a93ab5b7c32eb3.js +0 -1
  215. package/out/_next/static/chunks/pages/_error-2d792b2a41857be4.js +0 -1
  216. package/out/_next/static/css/8968d98ed4c4d33f.css +0 -1
  217. package/src/components/BillingResult.tsx +0 -447
  218. package/src/components/CloudSessionProvider.tsx +0 -130
  219. package/src/components/SessionExpiredModal.tsx +0 -128
  220. package/src/components/WorkspaceStatusIndicator.tsx +0 -396
  221. package/src/components/hooks/useSession.ts +0 -209
  222. package/src/components/hooks/useWorkspaceMembers.ts +0 -132
  223. package/src/components/hooks/useWorkspaceStatus.ts +0 -237
  224. package/src/components/settings/BillingSettingsPanel.tsx +0 -564
  225. package/src/components/settings/TeamSettingsPanel.tsx +0 -560
  226. package/src/components/settings/WorkspaceSettingsPanel.tsx +0 -1368
  227. package/src/lib/cloudApi.ts +0 -893
  228. /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_ssgManifest.js +0 -0
@@ -1,203 +1,65 @@
1
1
  /**
2
2
  * Dashboard V2 - Main Application Component
3
3
  *
4
- * Root component that combines sidebar, header, and main content area.
5
- * Manages global state via hooks and provides context to child components.
4
+ * Layout shell that composes the provider tree and renders the sidebar,
5
+ * header, main content area, and modal overlays. All business logic lives
6
+ * in the provider layer (see src/providers/).
6
7
  */
7
8
 
8
9
  import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
9
- import type { Agent, Project, Message, AgentSummary, ActivityEvent, Reaction } from '../types';
10
+ import type { Agent, Message } from '../types';
10
11
  import { ActivityFeed } from './ActivityFeed';
11
12
  import { Sidebar } from './layout/Sidebar';
12
13
  import { Header } from './layout/Header';
13
14
  import { MessageList } from './MessageList';
14
15
  import { ThreadPanel } from './ThreadPanel';
15
- import { CommandPalette, type TaskCreateRequest, PRIORITY_CONFIG } from './CommandPalette';
16
- import { SpawnModal, type SpawnConfig } from './SpawnModal';
16
+ import { CommandPalette, PRIORITY_CONFIG } from './CommandPalette';
17
+ import { SpawnModal } from './SpawnModal';
17
18
  import { NewConversationModal } from './NewConversationModal';
18
- import { SettingsPage, defaultSettings, type Settings } from './settings';
19
+ import { SettingsPage } from './settings';
19
20
  import { ConversationHistory } from './ConversationHistory';
20
- import type { HumanUser } from './MentionAutocomplete';
21
21
  import { NotificationToast, useToasts } from './NotificationToast';
22
- import { WorkspaceSelector, type Workspace } from './WorkspaceSelector';
22
+ import { WorkspaceSelector } from './WorkspaceSelector';
23
23
  import { AddWorkspaceModal } from './AddWorkspaceModal';
24
24
  import { LogViewerPanel } from './LogViewerPanel';
25
25
  import { TrajectoryViewer } from './TrajectoryViewer';
26
- import { DecisionQueue, type Decision } from './DecisionQueue';
26
+ import { DecisionQueue } from './DecisionQueue';
27
27
  import { FleetOverview } from './FleetOverview';
28
- import type { ServerInfo } from './ServerCard';
29
28
  import { TypingIndicator } from './TypingIndicator';
30
29
  import { MessageComposer } from './MessageComposer';
31
30
  import { OnlineUsersIndicator } from './OnlineUsersIndicator';
32
31
  import { UserProfilePanel } from './UserProfilePanel';
33
32
  import { AgentProfilePanel } from './AgentProfilePanel';
34
- import { useDirectMessage } from './hooks/useDirectMessage';
35
33
  import { CoordinatorPanel } from './CoordinatorPanel';
36
- import { BillingResult } from './BillingResult';
37
34
  import { UsageBanner } from './UsageBanner';
38
35
  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
36
  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';
37
+ import { useUrlRouting, type Route } from '../lib/useUrlRouting';
38
+ import { WorkspaceProvider } from './WorkspaceContext';
47
39
  import {
48
40
  ChannelViewV1,
49
- SearchInput,
50
41
  CreateChannelModal,
51
42
  InviteToChannelModal,
52
43
  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
44
  type ChannelMessage as ChannelApiMessage,
63
- type UnreadState,
64
- type CreateChannelRequest,
65
45
  } 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
46
 
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
- }
47
+ // Providers
48
+ import {
49
+ SettingsProvider,
50
+ useSettings,
51
+ CloudWorkspaceProvider,
52
+ useCloudWorkspace,
53
+ RelayConfigProvider,
54
+ AgentProvider,
55
+ useAgentContext,
56
+ MessageProvider,
57
+ useMessageContext,
58
+ ACTIVITY_FEED_ID,
59
+ } from '../providers';
60
+
61
+ // Re-export for backwards compatibility (MessageList imports this)
62
+ export { ACTIVITY_FEED_ID };
201
63
 
202
64
  export interface AppProps {
203
65
  /** Initial WebSocket URL (optional, defaults to current host) */
@@ -208,639 +70,247 @@ export interface AppProps {
208
70
  enableReactions?: boolean;
209
71
  }
210
72
 
211
- const REACTION_OVERRIDE_TTL = 5000; // 5s — enough for API round-trip + WS echo
212
-
73
+ /**
74
+ * Outer shell: sets up WebSocket + reaction merge, then wraps in providers.
75
+ */
213
76
  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
77
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
78
  const wsEventHandlerRef = useRef<((event: any) => void) | undefined>(undefined);
218
79
 
219
- // WebSocket connection for real-time data (per-project daemon)
220
- // Pass event handler for direct_message/channel_message events in local mode
221
80
  const { data: wsData, isConnected, error: wsError } = useWebSocket({
222
81
  url: wsUrl,
223
82
  onEvent: (event) => wsEventHandlerRef.current?.(event),
224
83
  });
225
84
 
226
- // REST fallback: fetch initial data when WebSocket fails
85
+ // REST fallback
227
86
  const [restData, setRestData] = useState<DashboardData | null>(null);
228
87
  const [restFallbackFailed, setRestFallbackFailed] = useState(false);
229
88
  useEffect(() => {
230
89
  if (wsError && !wsData && !restData) {
231
90
  let cancelled = false;
232
91
  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);
92
+ (async () => {
93
+ try {
94
+ const { api } = await import('../lib/api');
95
+ const resp = await api.getData();
96
+ if (cancelled) return;
97
+ if (resp.success && resp.data) {
98
+ setRestData(resp.data as DashboardData);
99
+ } else {
100
+ setRestFallbackFailed(true);
101
+ }
102
+ } catch {
103
+ if (!cancelled) setRestFallbackFailed(true);
239
104
  }
240
- }).catch(() => { if (!cancelled) setRestFallbackFailed(true); });
105
+ })();
241
106
  return () => { cancelled = true; };
242
107
  }
243
108
  }, [wsError, wsData, restData]);
244
109
 
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]);
110
+ const cloudFallbackData: DashboardData | null = wsUrl ? { agents: [], messages: [] } : null;
111
+ const rawData = wsData || restData || cloudFallbackData;
112
+ const data = rawData; // reaction merging now happens in MessageProvider
372
113
 
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'
114
+ return (
115
+ <SettingsProvider>
116
+ <WorkspaceProvider wsUrl={wsUrl}>
117
+ <CloudWorkspaceProvider orchestratorUrl={orchestratorUrl}>
118
+ <RelayConfigProvider>
119
+ <AgentProvider data={data} isConnected={isConnected}>
120
+ <MessageProvider data={data} rawData={rawData} enableReactions={enableReactions}>
121
+ <AppShell
122
+ wsUrl={wsUrl}
123
+ data={data}
124
+ rawData={rawData}
125
+ isConnected={isConnected}
126
+ wsError={wsError}
127
+ restFallbackFailed={restFallbackFailed}
128
+ enableReactions={enableReactions}
129
+ wsEventHandlerRef={wsEventHandlerRef}
130
+ />
131
+ </MessageProvider>
132
+ </AgentProvider>
133
+ </RelayConfigProvider>
134
+ </CloudWorkspaceProvider>
135
+ </WorkspaceProvider>
136
+ </SettingsProvider>
494
137
  );
138
+ }
495
139
 
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
- };
140
+ // ---------------------------------------------------------------------------
141
+ // Inner layout shell -- consumes all providers
142
+ // ---------------------------------------------------------------------------
568
143
 
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]);
144
+ interface AppShellProps {
145
+ wsUrl?: string;
146
+ data: DashboardData | null;
147
+ rawData: DashboardData | null;
148
+ isConnected: boolean;
149
+ wsError: Error | null;
150
+ restFallbackFailed: boolean;
151
+ enableReactions: boolean;
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ wsEventHandlerRef: React.MutableRefObject<((event: any) => void) | undefined>;
154
+ }
686
155
 
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)
156
+ function AppShell({
157
+ data,
158
+ isConnected,
159
+ wsError,
160
+ restFallbackFailed,
161
+ enableReactions,
162
+ wsEventHandlerRef,
163
+ }: AppShellProps) {
164
+ const { settings, updateSettings } = useSettings();
165
+ const {
166
+ cloudUser,
167
+ currentUser,
168
+ isWorkspaceFeaturesEnabled,
169
+ canOpenHeaderSettings,
170
+ canOpenWorkspaceSettings,
171
+ effectiveWorkspaces,
172
+ effectiveActiveWorkspaceId,
173
+ effectiveIsLoading,
174
+ isOrchestratorConnected,
175
+ orchestratorWorkspaces,
176
+ handleEffectiveWorkspaceSelect,
177
+ features,
178
+ apiAdapter,
179
+ } = useCloudWorkspace();
180
+ const {
181
+ agents,
182
+ combinedAgents,
183
+ selectedAgent,
184
+ selectAgent,
185
+ agentSummariesMap,
186
+ mergedProjects,
187
+ currentProject,
188
+ setCurrentProject,
189
+ bridgeAgents,
190
+ localAgentsForSidebar,
191
+ addRecentRepo,
192
+ getRecentProjects,
193
+ workspaceRepos,
194
+ refetchWorkspaceRepos,
195
+ handleSpawn,
196
+ handleReleaseAgent,
197
+ isSpawnModalOpen,
198
+ setIsSpawnModalOpen,
199
+ isSpawning,
200
+ spawnError,
201
+ setSpawnError,
202
+ isFleetAvailable,
203
+ isFleetViewActive,
204
+ setIsFleetViewActive,
205
+ fleetServers,
206
+ selectedServerId,
207
+ setSelectedServerId,
208
+ handleServerReconnect,
209
+ isDecisionQueueOpen,
210
+ setIsDecisionQueueOpen,
211
+ decisions,
212
+ decisionProcessing,
213
+ handleDecisionApprove,
214
+ handleDecisionReject,
215
+ handleDecisionDismiss,
216
+ handleTaskCreate,
217
+ activityEvents,
218
+ logViewerAgent,
219
+ setLogViewerAgent,
220
+ selectedAgentProfile,
221
+ setSelectedAgentProfile,
222
+ } = useAgentContext();
223
+ const {
224
+ messages,
225
+ currentChannel,
226
+ setCurrentChannel,
227
+ currentThread,
228
+ setCurrentThread,
229
+ activeThreads,
230
+ totalUnreadThreadCount,
231
+ sendMessage,
232
+ isSending,
233
+ sendError,
234
+ thread,
235
+ viewMode,
236
+ setViewMode,
237
+ channelsList,
238
+ archivedChannelsList,
239
+ channelMessages: _channelMessages,
240
+ selectedChannelId,
241
+ setSelectedChannelId,
242
+ selectedChannel,
243
+ hasMoreMessages,
244
+ channelUnreadState,
245
+ effectiveChannelMessages,
246
+ handleSelectChannel,
247
+ handleCreateChannel,
248
+ handleCreateChannelSubmit,
249
+ handleInviteToChannel,
250
+ handleInviteSubmit,
251
+ handleLeaveChannel,
252
+ handleShowMembers,
253
+ handleRemoveMember,
254
+ handleAddMember,
255
+ handleArchiveChannel,
256
+ handleUnarchiveChannel,
257
+ handleSendChannelMessage,
258
+ handleLoadMoreMessages,
259
+ isCreateChannelOpen,
260
+ setIsCreateChannelOpen,
261
+ isCreatingChannel,
262
+ isInviteChannelOpen,
263
+ setIsInviteChannelOpen,
264
+ inviteChannelTarget,
265
+ setInviteChannelTarget,
266
+ isInvitingToChannel,
267
+ showMemberPanel,
268
+ setShowMemberPanel,
269
+ channelMembers,
270
+ currentHuman,
271
+ selectedDmAgents,
272
+ dedupedVisibleMessages,
273
+ dmSelectedAgentsByHuman,
274
+ handleDmAgentToggle,
275
+ handleMainComposerSend,
276
+ onlineUsers,
277
+ typingUsers,
278
+ sendTyping,
279
+ humanUsers,
280
+ humanUnreadCounts,
281
+ handleReaction,
282
+ markDmSeen,
283
+ selectedUserProfile,
284
+ setSelectedUserProfile,
285
+ pendingMention,
286
+ setPendingMention,
287
+ hasUnreadMessages,
288
+ handlePresenceEvent,
289
+ } = useMessageContext();
290
+
291
+ // Keep the WS event handler ref in sync
689
292
  wsEventHandlerRef.current = handlePresenceEvent;
690
293
 
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>();
294
+ // ---------------------------------------------------------------------------
295
+ // UI-only state
296
+ // ---------------------------------------------------------------------------
796
297
 
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
298
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
803
299
  const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = useState(false);
804
300
  const [isAddingWorkspace, setIsAddingWorkspace] = useState(false);
805
301
  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
302
  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
303
  const [isFullSettingsOpen, setIsFullSettingsOpen] = useState(false);
827
304
  const [settingsInitialTab, setSettingsInitialTab] = useState<'dashboard' | 'workspace' | 'team' | 'billing'>('dashboard');
828
-
829
- // Conversation history panel state
830
305
  const [isHistoryOpen, setIsHistoryOpen] = useState(false);
831
-
832
- // New conversation modal state
833
306
  const [isNewConversationOpen, setIsNewConversationOpen] = useState(false);
307
+ const [isCoordinatorOpen, setIsCoordinatorOpen] = useState(false);
308
+ const [isTrajectoryOpen, setIsTrajectoryOpen] = useState(false);
834
309
 
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);
310
+ const { toasts, addToast, dismissToast } = useToasts();
311
+ const [authRevokedAgents, setAuthRevokedAgents] = useState<Set<string>>(new Set());
841
312
 
842
- // Trajectory panel state
843
- const [isTrajectoryOpen, setIsTrajectoryOpen] = useState(false);
313
+ // Trajectory
844
314
  const {
845
315
  steps: trajectorySteps,
846
316
  status: trajectoryStatus,
@@ -848,318 +318,17 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
848
318
  isLoading: isTrajectoryLoading,
849
319
  selectTrajectory,
850
320
  selectedTrajectoryId,
851
- } = useTrajectory({
852
- autoPoll: isTrajectoryOpen, // Only poll when panel is open
853
- });
321
+ } = useTrajectory({ autoPoll: isTrajectoryOpen });
854
322
 
855
- // Get the title of the selected trajectory from history
856
323
  const selectedTrajectoryTitle = useMemo(() => {
857
324
  if (!selectedTrajectoryId) return null;
858
325
  return trajectoryHistory.find(t => t.id === selectedTrajectoryId)?.title ?? null;
859
326
  }, [selectedTrajectoryId, trajectoryHistory]);
860
327
 
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]);
328
+ // ---------------------------------------------------------------------------
329
+ // URL routing
330
+ // ---------------------------------------------------------------------------
1133
331
 
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
332
  const handleRouteChange = useCallback((route: Route) => {
1164
333
  switch (route.type) {
1165
334
  case 'channel':
@@ -1185,7 +354,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
1185
354
  setSelectedChannelId(ACTIVITY_FEED_ID);
1186
355
  break;
1187
356
  }
1188
- }, [setCurrentChannel]);
357
+ }, [setCurrentChannel, setViewMode, setSelectedChannelId]);
1189
358
 
1190
359
  const {
1191
360
  navigateToChannel,
@@ -1196,400 +365,32 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
1196
365
  closeSettings: urlCloseSettings,
1197
366
  } = useUrlRouting({ onRouteChange: handleRouteChange });
1198
367
 
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;
368
+ // ---------------------------------------------------------------------------
369
+ // Handlers (UI wiring only -- no business logic)
370
+ // ---------------------------------------------------------------------------
1220
371
 
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
372
+ const closeSidebarOnMobile = useCallback(() => {
373
+ if (window.innerWidth <= 768) setIsSidebarOpen(false);
1242
374
  }, []);
1243
375
 
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]);
376
+ const handleAgentSelect = useCallback((agent: Agent) => {
377
+ setViewMode('local');
378
+ setSelectedChannelId(undefined);
379
+ selectAgent(agent.name);
380
+ setCurrentChannel(agent.name);
381
+ navigateToAgent(agent.name);
382
+ closeSidebarOnMobile();
383
+ }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, navigateToAgent, setViewMode, setSelectedChannelId]);
1579
384
 
1580
- // Handle project selection (also switches workspace if using orchestrator)
1581
- const handleProjectSelect = useCallback((project: Project) => {
385
+ const { addWorkspace: cwAddWorkspace, switchWorkspace: cwSwitchWorkspace } = useCloudWorkspace();
386
+ const handleProjectSelect = useCallback((project: { id: string; name?: string; path: string; agents: Agent[] }) => {
1582
387
  setCurrentProject(project.id);
1583
- // Switch to DM view mode and clear channel selection
1584
388
  setViewMode('local');
1585
389
  setSelectedChannelId(undefined);
1586
-
1587
- // Track as recently accessed
1588
390
  addRecentRepo(project);
1589
391
 
1590
- // Switch workspace if using orchestrator
1591
- if (workspaces.length > 0) {
1592
- switchWorkspace(project.id).catch((err) => {
392
+ if (orchestratorWorkspaces.length > 0) {
393
+ cwSwitchWorkspace(project.id).catch((err: unknown) => {
1593
394
  console.error('Failed to switch workspace:', err);
1594
395
  });
1595
396
  }
@@ -1599,589 +400,144 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
1599
400
  setCurrentChannel(project.agents[0].name);
1600
401
  }
1601
402
  closeSidebarOnMobile();
1602
- }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, workspaces.length, switchWorkspace, addRecentRepo]);
403
+ }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, orchestratorWorkspaces.length, addRecentRepo, setViewMode, setSelectedChannelId, setCurrentProject, cwSwitchWorkspace]);
1603
404
 
1604
- // Handle agent selection
1605
- const handleAgentSelect = useCallback((agent: Agent) => {
1606
- // Switch to DM view mode and clear channel selection
405
+ const handleHumanSelect = useCallback((human: Agent) => {
1607
406
  setViewMode('local');
1608
407
  setSelectedChannelId(undefined);
1609
- selectAgent(agent.name);
1610
- setCurrentChannel(agent.name);
1611
- navigateToAgent(agent.name);
408
+ setCurrentChannel(human.name);
409
+ markDmSeen(human.name);
410
+ navigateToDm(human.name);
1612
411
  closeSidebarOnMobile();
1613
- }, [selectAgent, setCurrentChannel, closeSidebarOnMobile, navigateToAgent]);
412
+ }, [closeSidebarOnMobile, markDmSeen, setCurrentChannel, navigateToDm, setViewMode, setSelectedChannelId]);
1614
413
 
1615
- // Handle spawn button click
1616
414
  const handleSpawnClick = useCallback(() => {
1617
415
  setSpawnError(null);
1618
416
  setIsSpawnModalOpen(true);
1619
- }, []);
417
+ }, [setSpawnError, setIsSpawnModalOpen]);
1620
418
 
1621
- // Handle settings click - opens full settings page
1622
419
  const handleSettingsClick = useCallback(() => {
1623
420
  setSettingsInitialTab('dashboard');
1624
421
  setIsFullSettingsOpen(true);
1625
422
  navigateToSettings('dashboard');
1626
423
  }, [navigateToSettings]);
1627
424
 
1628
- // Handle workspace settings click - opens full settings page with workspace tab
1629
425
  const handleWorkspaceSettingsClick = useCallback(() => {
1630
426
  setSettingsInitialTab('workspace');
1631
427
  setIsFullSettingsOpen(true);
1632
428
  navigateToSettings('workspace');
1633
429
  }, [navigateToSettings]);
1634
430
 
1635
- // Handle billing click - opens full settings page with billing tab
1636
431
  const handleBillingClick = useCallback(() => {
1637
432
  setSettingsInitialTab('billing');
1638
433
  setIsFullSettingsOpen(true);
1639
434
  navigateToSettings('billing');
1640
435
  }, [navigateToSettings]);
1641
436
 
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]);
437
+ const handleLogsClick = useCallback((agent: Agent) => {
438
+ setLogViewerAgent(agent);
439
+ }, [setLogViewerAgent]);
1667
440
 
1668
- // Handle channel member click - switch to DM with that member
1669
441
  const handleChannelMemberClick = useCallback((memberId: string, entityType: 'user' | 'agent') => {
1670
- // Don't navigate to self
1671
442
  if (memberId === currentUser?.displayName) return;
1672
-
1673
- // Switch from channel view to local (DM) view
1674
443
  setViewMode('local');
1675
444
  setSelectedChannelId(undefined);
1676
-
1677
- // Select the agent or user
1678
445
  if (entityType === 'agent') {
1679
446
  selectAgent(memberId);
1680
447
  setCurrentChannel(memberId);
1681
448
  } else {
1682
- // For users, just set the channel
1683
449
  setCurrentChannel(memberId);
1684
450
  }
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
451
  closeSidebarOnMobile();
452
+ }, [currentUser?.displayName, selectAgent, setCurrentChannel, closeSidebarOnMobile, setViewMode, setSelectedChannelId]);
1801
453
 
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);
454
+ const handleNewConversationSend = useCallback(async (to: string, content: string): Promise<boolean> => {
455
+ const success = await sendMessage(to, content);
456
+ if (success) {
457
+ const targetAgent = agents.find((a) => a.name === to);
458
+ if (targetAgent) {
459
+ selectAgent(targetAgent.name);
460
+ setCurrentChannel(targetAgent.name);
461
+ } else {
462
+ setCurrentChannel(to);
1828
463
  }
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
464
  }
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
- }, []);
465
+ return success;
466
+ }, [sendMessage, selectAgent, setCurrentChannel, agents]);
1843
467
 
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);
468
+ const handleAddWorkspace = useCallback(async (path: string, name?: string) => {
469
+ setIsAddingWorkspace(true);
470
+ setAddWorkspaceError(null);
1849
471
  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);
472
+ await cwAddWorkspace(path, name);
473
+ setIsAddWorkspaceOpen(false);
1875
474
  } catch (err) {
1876
- console.error('Failed to invite to channel:', err);
475
+ setAddWorkspaceError(err instanceof Error ? err.message : 'Failed to add workspace');
476
+ throw err;
1877
477
  } 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);
478
+ setIsAddingWorkspace(false);
2001
479
  }
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 });
480
+ }, [cwAddWorkspace]);
2022
481
 
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] }];
482
+ // Auth revocation detection
483
+ useEffect(() => {
484
+ if (!data?.messages) return;
485
+ for (const msg of data.messages) {
486
+ if (msg.content?.includes('auth_revoked') || msg.content?.includes('authentication_error')) {
487
+ try {
488
+ const parsed = JSON.parse(msg.content);
489
+ if (parsed.type === 'auth_revoked' && parsed.agent) {
490
+ const agentName = parsed.agent;
491
+ if (!authRevokedAgents.has(agentName)) {
492
+ setAuthRevokedAgents(prev => new Set([...prev, agentName]));
493
+ addToast({
494
+ type: 'error',
495
+ title: 'Authentication Expired',
496
+ message: `${agentName}'s API credentials have expired. Please reconnect.`,
497
+ agentName,
498
+ duration: 0,
499
+ action: {
500
+ label: 'Reconnect',
501
+ onClick: () => { window.location.href = '/providers'; },
502
+ },
503
+ });
504
+ }
505
+ }
506
+ } catch {
507
+ if (msg.content?.includes('OAuth token') && msg.content?.includes('expired')) {
508
+ const agentName = msg.from;
509
+ if (agentName && !authRevokedAgents.has(agentName)) {
510
+ setAuthRevokedAgents(prev => new Set([...prev, agentName]));
511
+ addToast({
512
+ type: 'error',
513
+ title: 'Authentication Expired',
514
+ message: `${agentName}'s API credentials have expired. Please reconnect.`,
515
+ agentName,
516
+ duration: 0,
517
+ action: {
518
+ label: 'Reconnect',
519
+ onClick: () => { window.location.href = '/providers'; },
520
+ },
521
+ });
522
+ }
523
+ }
2063
524
  }
2064
525
  }
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
526
  }
527
+ }, [data?.messages, authRevokedAgents, addToast]);
2093
528
 
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
529
+ // Mark DM as seen when viewing a human channel
2120
530
  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
- }
531
+ if (!currentUser || !currentChannel) return;
532
+ const humanNameSet = new Set(
533
+ combinedAgents.filter((a) => a.isHuman).map((a) => a.name.toLowerCase())
534
+ );
535
+ if (humanNameSet.has(currentChannel.toLowerCase())) {
536
+ markDmSeen(currentChannel);
2167
537
  }
538
+ }, [combinedAgents, currentChannel, currentUser, markDmSeen]);
2168
539
 
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
-
540
+ // DM invite commands for command palette
2185
541
  const dmInviteCommands = useMemo(() => {
2186
542
  if (!currentHuman) return [];
2187
543
  return agents
@@ -2201,39 +557,19 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2201
557
  // Channel commands for command palette
2202
558
  const channelCommands = useMemo(() => {
2203
559
  const commands: Array<{
2204
- id: string;
2205
- label: string;
2206
- description?: string;
2207
- category: 'channels';
2208
- shortcut?: string;
2209
- action: () => void;
560
+ id: string; label: string; description?: string;
561
+ category: 'channels'; shortcut?: string; action: () => void;
2210
562
  }> = [];
2211
-
2212
- // Switch to channels view
2213
563
  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
- },
564
+ id: 'channels-view', label: 'Go to Channels',
565
+ description: 'Switch to channel messaging view', category: 'channels',
566
+ shortcut: 'Cmd+Shift+C', action: () => setViewMode('channels'),
2222
567
  });
2223
-
2224
- // Create new channel
2225
568
  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
- },
569
+ id: 'channels-create', label: 'Create Channel',
570
+ description: 'Create a new messaging channel', category: 'channels',
571
+ action: () => { setViewMode('channels'); handleCreateChannel(); },
2234
572
  });
2235
-
2236
- // Add each channel as a quick-switch command
2237
573
  channelsList.forEach((channel) => {
2238
574
  const unreadBadge = channel.unreadCount > 0 ? ` (${channel.unreadCount} unread)` : '';
2239
575
  commands.push({
@@ -2241,431 +577,31 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2241
577
  label: channel.isDm ? `@${channel.name}` : `#${channel.name}`,
2242
578
  description: channel.description || `Switch to ${channel.isDm ? 'DM' : 'channel'}${unreadBadge}`,
2243
579
  category: 'channels',
2244
- action: () => {
2245
- setViewMode('channels');
2246
- setSelectedChannelId(channel.id);
2247
- },
580
+ action: () => { setViewMode('channels'); setSelectedChannelId(channel.id); },
2248
581
  });
2249
582
  });
2250
-
2251
583
  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]);
584
+ }, [channelsList, handleCreateChannel, setViewMode, setSelectedChannelId]);
2645
585
 
2646
586
  // Keyboard shortcuts
2647
- React.useEffect(() => {
587
+ useEffect(() => {
2648
588
  const handleKeyDown = (e: KeyboardEvent) => {
2649
589
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
2650
590
  e.preventDefault();
2651
591
  setIsCommandPaletteOpen(true);
2652
592
  }
2653
-
2654
593
  if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 's') {
2655
594
  e.preventDefault();
2656
595
  handleSpawnClick();
2657
596
  }
2658
-
2659
597
  if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
2660
598
  e.preventDefault();
2661
599
  setViewMode('channels');
2662
600
  }
2663
-
2664
601
  if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
2665
602
  e.preventDefault();
2666
- handleNewConversationClick();
603
+ setIsNewConversationOpen(true);
2667
604
  }
2668
-
2669
605
  if (e.key === 'Escape') {
2670
606
  setIsCommandPaletteOpen(false);
2671
607
  setIsSpawnModalOpen(false);
@@ -2680,37 +616,13 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2680
616
 
2681
617
  window.addEventListener('keydown', handleKeyDown);
2682
618
  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
- }
619
+ }, [handleSpawnClick, isFullSettingsOpen, urlCloseSettings, setIsSpawnModalOpen, setViewMode]);
620
+
621
+ // =========================================================================
622
+ // RENDER
623
+ // =========================================================================
2711
624
 
2712
625
  return (
2713
- <WorkspaceProvider wsUrl={wsUrl}>
2714
626
  <div className="flex h-screen bg-bg-deep font-sans text-text-primary">
2715
627
  {/* Mobile Sidebar Overlay */}
2716
628
  <div
@@ -2722,36 +634,30 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2722
634
  onClick={() => setIsSidebarOpen(false)}
2723
635
  />
2724
636
 
2725
- {/* Sidebar with Workspace Selector */}
637
+ {/* Sidebar */}
2726
638
  <div className={`
2727
639
  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
640
  fixed left-0 top-0 z-[1000] transition-transform duration-200
2729
641
  md:relative md:translate-x-0 md:flex-shrink-0
2730
642
  ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
2731
643
  `}>
2732
- {/* Workspace Selector */}
2733
644
  <div className="p-3 border-b border-sidebar-border">
2734
645
  <WorkspaceSelector
2735
646
  workspaces={effectiveWorkspaces}
2736
647
  activeWorkspaceId={effectiveActiveWorkspaceId ?? undefined}
2737
648
  onSelect={handleEffectiveWorkspaceSelect}
2738
649
  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
650
+ if (features.workspaces) {
2742
651
  localStorage.removeItem('agentrelay_workspace_id');
2743
652
  window.location.href = '/app?select=true';
2744
653
  } else {
2745
- // In local mode, show the add workspace modal
2746
654
  setIsAddWorkspaceOpen(true);
2747
655
  }
2748
656
  }}
2749
- onWorkspaceSettings={handleWorkspaceSettingsClick}
657
+ onWorkspaceSettings={canOpenWorkspaceSettings ? handleWorkspaceSettingsClick : undefined}
2750
658
  isLoading={effectiveIsLoading}
2751
659
  />
2752
660
  </div>
2753
-
2754
- {/* Unified Sidebar - Channels collapsed by default, Agents always visible */}
2755
661
  <Sidebar
2756
662
  agents={localAgentsForSidebar}
2757
663
  bridgeAgents={bridgeAgents}
@@ -2769,20 +675,10 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2769
675
  totalUnreadThreadCount={totalUnreadThreadCount}
2770
676
  channels={channelsList
2771
677
  .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
- }))}
678
+ .map(c => ({ id: c.id, name: c.name, unreadCount: c.unreadCount, hasMentions: c.hasMentions }))}
2778
679
  archivedChannels={archivedChannelsList
2779
680
  .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
- }))}
681
+ .map(c => ({ id: c.id, name: c.name, unreadCount: c.unreadCount ?? 0, hasMentions: c.hasMentions }))}
2786
682
  selectedChannelId={selectedChannelId}
2787
683
  isActivitySelected={selectedChannelId === ACTIVITY_FEED_ID}
2788
684
  activityUnreadCount={0}
@@ -2804,23 +700,17 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2804
700
  onCreateChannel={handleCreateChannel}
2805
701
  onInviteToChannel={(channel) => {
2806
702
  const fullChannel = channelsList.find(c => c.id === channel.id);
2807
- if (fullChannel) {
2808
- handleInviteToChannel(fullChannel);
2809
- }
703
+ if (fullChannel) handleInviteToChannel(fullChannel);
2810
704
  }}
2811
705
  onArchiveChannel={(channel) => {
2812
- const fullChannel = channelsList.find((c) => c.id === channel.id);
2813
- if (fullChannel) {
2814
- handleArchiveChannel(fullChannel);
2815
- }
706
+ const fullChannel = channelsList.find(c => c.id === channel.id);
707
+ if (fullChannel) handleArchiveChannel(fullChannel);
2816
708
  }}
2817
709
  onUnarchiveChannel={(channel) => {
2818
710
  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
- }
711
+ archivedChannelsList.find(c => c.id === channel.id) ||
712
+ channelsList.find(c => c.id === channel.id);
713
+ if (fullChannel) handleUnarchiveChannel(fullChannel);
2824
714
  }}
2825
715
  onAgentSelect={handleAgentSelect}
2826
716
  onHumanSelect={handleHumanSelect}
@@ -2842,46 +732,38 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2842
732
 
2843
733
  {/* Main Content */}
2844
734
  <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
735
  <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
736
  <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} />
737
+ currentChannel={currentChannel}
738
+ selectedAgent={selectedAgent}
739
+ projects={mergedProjects}
740
+ currentProject={mergedProjects.find(p => p.id === currentProject) || null}
741
+ recentProjects={getRecentProjects(mergedProjects)}
742
+ viewMode={viewMode}
743
+ selectedChannelName={selectedChannel?.name}
744
+ onProjectChange={handleProjectSelect}
745
+ onCommandPaletteOpen={() => setIsCommandPaletteOpen(true)}
746
+ onSettingsClick={canOpenHeaderSettings ? handleSettingsClick : undefined}
747
+ onHistoryClick={() => setIsHistoryOpen(true)}
748
+ onNewConversationClick={() => setIsNewConversationOpen(true)}
749
+ onFleetClick={() => setIsFleetViewActive(!isFleetViewActive)}
750
+ isFleetViewActive={isFleetViewActive}
751
+ onTrajectoryClick={() => setIsTrajectoryOpen(true)}
752
+ hasActiveTrajectory={trajectoryStatus?.active}
753
+ onMenuClick={() => setIsSidebarOpen(true)}
754
+ hasUnreadNotifications={hasUnreadMessages}
755
+ />
756
+ <UsageBanner onUpgradeClick={handleBillingClick} />
2869
757
  </div>
2870
- {/* Spacer for fixed header on mobile - matches header height (52px) */}
2871
758
  <div className="h-[52px] flex-shrink-0 md:hidden" />
2872
- {/* Online users indicator - outside fixed header so it scrolls with content on mobile */}
2873
759
  {currentUser && onlineUsers.length > 0 && (
2874
760
  <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
- />
761
+ <OnlineUsersIndicator onlineUsers={onlineUsers} onUserClick={setSelectedUserProfile} />
2879
762
  </div>
2880
763
  )}
2881
764
 
2882
765
  {/* Content Area */}
2883
766
  <div className="flex-1 flex overflow-hidden min-h-0">
2884
- {/* Message List */}
2885
767
  <div className={`flex-1 min-h-0 overflow-y-auto ${currentThread ? 'hidden md:block md:flex-[2]' : ''}`}>
2886
768
  {currentHuman && (
2887
769
  <div className="px-4 py-2 border-b border-border-subtle bg-bg-secondary flex flex-col gap-2 sticky top-0 z-10">
@@ -2904,7 +786,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2904
786
  }`}
2905
787
  title={agent.name}
2906
788
  >
2907
- {isSelected ? ' ' : ''}{agent.name}
789
+ {isSelected ? 'v ' : ''}{agent.name}
2908
790
  </button>
2909
791
  );
2910
792
  })}
@@ -2943,24 +825,25 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2943
825
  />
2944
826
  </div>
2945
827
  ) : selectedChannelId === ACTIVITY_FEED_ID ? (
2946
- <ActivityFeed
2947
- events={activityEvents}
2948
- maxEvents={100}
2949
- />
828
+ <ActivityFeed events={activityEvents} maxEvents={100} />
2950
829
  ) : viewMode === 'channels' && selectedChannel ? (
2951
830
  <ChannelViewV1
2952
831
  channel={selectedChannel}
2953
832
  messages={effectiveChannelMessages}
2954
833
  currentUser={currentUser?.displayName || 'Anonymous'}
834
+ currentUserInfo={currentUser ? { displayName: currentUser.displayName, avatarUrl: currentUser.avatarUrl } : undefined}
835
+ onlineUsers={onlineUsers}
836
+ agents={agents}
837
+ humanUsers={humanUsers}
2955
838
  isLoadingMore={false}
2956
839
  hasMoreMessages={hasMoreMessages && !!effectiveActiveWorkspaceId}
2957
- mentionSuggestions={agents.map(a => a.name)}
2958
840
  unreadState={channelUnreadState}
2959
- onSendMessage={handleSendChannelMessage}
841
+ onSendMessage={(content, attachmentIds) => handleSendChannelMessage(content, undefined, attachmentIds)}
2960
842
  onLoadMore={handleLoadMoreMessages}
2961
843
  onThreadClick={(messageId) => setCurrentThread(messageId)}
2962
844
  onShowMembers={handleShowMembers}
2963
845
  onMemberClick={handleChannelMemberClick}
846
+ onReaction={enableReactions ? handleReaction : undefined}
2964
847
  />
2965
848
  ) : viewMode === 'channels' ? (
2966
849
  <div className="flex flex-col items-center justify-center h-full text-text-muted text-center px-4">
@@ -2994,17 +877,10 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
2994
877
  {currentThread && (() => {
2995
878
  const isChannelView = viewMode === 'channels';
2996
879
 
2997
- // Helper to convert ChannelMessage to Message format for ThreadPanel
2998
880
  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,
881
+ id: cm.id, from: cm.from, to: cm.channelId, content: cm.content,
882
+ timestamp: cm.timestamp, thread: cm.threadId, isRead: cm.isRead,
883
+ replyCount: cm.threadSummary?.replyCount, threadSummary: cm.threadSummary,
3008
884
  });
3009
885
 
3010
886
  let originalMessage: Message | null = null;
@@ -3013,9 +889,16 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3013
889
  let threadIsLoading = false;
3014
890
  let threadHasMore = false;
3015
891
  let threadLoadMore: (() => void) | undefined;
892
+ const preferApiThreadDataInChannel = isChannelView && (thread.isLoading || Boolean(thread.parentMessage));
3016
893
 
3017
- if (isChannelView) {
3018
- // Channel view: use inline filtering (useThread doesn't handle ChannelApiMessage)
894
+ if (preferApiThreadDataInChannel) {
895
+ originalMessage = thread.parentMessage;
896
+ replies = thread.replies;
897
+ isTopicThread = !originalMessage;
898
+ threadIsLoading = thread.isLoading;
899
+ threadHasMore = thread.hasMore;
900
+ threadLoadMore = thread.loadMore;
901
+ } else if (isChannelView) {
3019
902
  const channelMsg = effectiveChannelMessages.find((m) => m.id === currentThread);
3020
903
  if (channelMsg) {
3021
904
  originalMessage = convertChannelMessage(channelMsg);
@@ -3024,15 +907,12 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3024
907
  const threadMsgs = effectiveChannelMessages
3025
908
  .filter((m) => m.threadId === currentThread)
3026
909
  .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
3027
- if (threadMsgs[0]) {
3028
- originalMessage = convertChannelMessage(threadMsgs[0]);
3029
- }
910
+ if (threadMsgs[0]) originalMessage = convertChannelMessage(threadMsgs[0]);
3030
911
  }
3031
912
  replies = effectiveChannelMessages
3032
913
  .filter((m) => m.threadId === currentThread)
3033
914
  .map(convertChannelMessage);
3034
915
  } else {
3035
- // Non-channel view: use the useThread hook (API-backed with fallback)
3036
916
  originalMessage = thread.parentMessage;
3037
917
  replies = thread.replies;
3038
918
  isTopicThread = !originalMessage;
@@ -3043,28 +923,20 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3043
923
 
3044
924
  return (
3045
925
  <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) => {
926
+ <ThreadPanel
927
+ originalMessage={originalMessage}
928
+ replies={replies}
929
+ onClose={() => setCurrentThread(null)}
930
+ showTimestamps={settings.display.showTimestamps}
931
+ isLoading={threadIsLoading}
932
+ hasMore={threadHasMore}
933
+ onLoadMore={threadLoadMore}
934
+ onReply={async (content) => {
3055
935
  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;
936
+ return handleSendChannelMessage(content, currentThread);
3066
937
  }
3067
- return sendMessage(recipient, content, currentThread);
938
+ // Use thread.sendReply (Relaycast SDK) when available
939
+ return thread.sendReply(content);
3068
940
  }}
3069
941
  isSending={isSending}
3070
942
  currentUser={currentUser}
@@ -3074,14 +946,12 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3074
946
  })()}
3075
947
  </div>
3076
948
 
3077
- {/* Typing Indicator */}
3078
949
  {typingUsers.length > 0 && (
3079
950
  <div className="px-4 bg-bg-tertiary border-t border-border-subtle">
3080
951
  <TypingIndicator typingUsers={typingUsers} />
3081
952
  </div>
3082
953
  )}
3083
954
 
3084
- {/* Message Composer - hide in channels mode (ChannelViewV1 has its own input) */}
3085
955
  {viewMode !== 'channels' && (
3086
956
  <div className="p-2 sm:p-4 bg-bg-tertiary border-t border-border-subtle">
3087
957
  <MessageComposer
@@ -3100,12 +970,12 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3100
970
  )}
3101
971
  </main>
3102
972
 
3103
- {/* Command Palette */}
973
+ {/* Modals & Overlays */}
3104
974
  <CommandPalette
3105
975
  isOpen={isCommandPaletteOpen}
3106
- onClose={handleCommandPaletteClose}
976
+ onClose={() => setIsCommandPaletteOpen(false)}
3107
977
  agents={agents}
3108
- projects={projects}
978
+ projects={mergedProjects}
3109
979
  currentProject={currentProject}
3110
980
  onAgentSelect={handleAgentSelect}
3111
981
  onProjectSelect={handleProjectSelect}
@@ -3119,7 +989,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3119
989
  customCommands={[...dmInviteCommands, ...channelCommands]}
3120
990
  />
3121
991
 
3122
- {/* Spawn Modal */}
3123
992
  <SpawnModal
3124
993
  isOpen={isSpawnModalOpen}
3125
994
  onClose={() => setIsSpawnModalOpen(false)}
@@ -3127,26 +996,24 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3127
996
  existingAgents={agents.map((a) => a.name)}
3128
997
  isSpawning={isSpawning}
3129
998
  error={spawnError}
3130
- isCloudMode={isCloudMode}
3131
999
  workspaceId={effectiveActiveWorkspaceId ?? undefined}
3132
1000
  agentDefaults={settings.agentDefaults}
3133
1001
  repos={workspaceRepos}
3134
1002
  activeRepoId={workspaceRepos.find(r => r.id === currentProject)?.id ?? workspaceRepos[0]?.id}
1003
+ connectedProviders={cloudUser?.connectedProviders?.map(p => {
1004
+ const BACKEND_TO_FRONTEND_MAP: Record<string, string> = { openai: 'codex' };
1005
+ return BACKEND_TO_FRONTEND_MAP[p.provider] ?? p.provider;
1006
+ })}
3135
1007
  />
3136
1008
 
3137
- {/* Add Workspace Modal */}
3138
1009
  <AddWorkspaceModal
3139
1010
  isOpen={isAddWorkspaceOpen}
3140
- onClose={() => {
3141
- setIsAddWorkspaceOpen(false);
3142
- setAddWorkspaceError(null);
3143
- }}
1011
+ onClose={() => { setIsAddWorkspaceOpen(false); setAddWorkspaceError(null); }}
3144
1012
  onAdd={handleAddWorkspace}
3145
1013
  isAdding={isAddingWorkspace}
3146
1014
  error={addWorkspaceError}
3147
1015
  />
3148
1016
 
3149
- {/* Create Channel Modal */}
3150
1017
  <CreateChannelModal
3151
1018
  isOpen={isCreateChannelOpen}
3152
1019
  onClose={() => setIsCreateChannelOpen(false)}
@@ -3157,20 +1024,15 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3157
1024
  workspaceId={effectiveActiveWorkspaceId ?? undefined}
3158
1025
  />
3159
1026
 
3160
- {/* Invite to Channel Modal */}
3161
1027
  <InviteToChannelModal
3162
1028
  isOpen={isInviteChannelOpen}
3163
1029
  channelName={inviteChannelTarget?.name || ''}
3164
- onClose={() => {
3165
- setIsInviteChannelOpen(false);
3166
- setInviteChannelTarget(null);
3167
- }}
1030
+ onClose={() => { setIsInviteChannelOpen(false); setInviteChannelTarget(null); }}
3168
1031
  onInvite={handleInviteSubmit}
3169
1032
  isLoading={isInvitingToChannel}
3170
1033
  availableMembers={agents.map(a => a.name)}
3171
1034
  />
3172
1035
 
3173
- {/* Member Management Panel */}
3174
1036
  {selectedChannel && (
3175
1037
  <MemberManagementPanel
3176
1038
  channel={selectedChannel}
@@ -3186,13 +1048,8 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3186
1048
  />
3187
1049
  )}
3188
1050
 
3189
- {/* Conversation History */}
3190
- <ConversationHistory
3191
- isOpen={isHistoryOpen}
3192
- onClose={() => setIsHistoryOpen(false)}
3193
- />
1051
+ <ConversationHistory isOpen={isHistoryOpen} onClose={() => setIsHistoryOpen(false)} />
3194
1052
 
3195
- {/* New Conversation Modal */}
3196
1053
  <NewConversationModal
3197
1054
  isOpen={isNewConversationOpen}
3198
1055
  onClose={() => setIsNewConversationOpen(false)}
@@ -3202,7 +1059,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3202
1059
  error={sendError}
3203
1060
  />
3204
1061
 
3205
- {/* Log Viewer Panel */}
3206
1062
  {logViewerAgent && (
3207
1063
  <LogViewerPanel
3208
1064
  agent={logViewerAgent}
@@ -3213,7 +1069,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3213
1069
  />
3214
1070
  )}
3215
1071
 
3216
- {/* Trajectory Panel - Fullscreen slide-over */}
1072
+ {/* Trajectory Panel */}
3217
1073
  {isTrajectoryOpen && (
3218
1074
  <div
3219
1075
  className="fixed inset-0 z-50 flex bg-black/50 backdrop-blur-sm"
@@ -3223,7 +1079,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3223
1079
  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
1080
  onClick={(e) => e.stopPropagation()}
3225
1081
  >
3226
- {/* Header */}
3227
1082
  <div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-bg-secondary">
3228
1083
  <div className="flex items-center gap-3">
3229
1084
  <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">
@@ -3248,8 +1103,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3248
1103
  </svg>
3249
1104
  </button>
3250
1105
  </div>
3251
-
3252
- {/* Content */}
3253
1106
  <div className="flex-1 overflow-hidden p-6">
3254
1107
  <TrajectoryViewer
3255
1108
  agentName={selectedTrajectoryTitle?.slice(0, 30) || trajectoryStatus?.task?.slice(0, 30) || 'Trajectories'}
@@ -3264,8 +1117,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3264
1117
  </div>
3265
1118
  )}
3266
1119
 
3267
-
3268
- {/* Decision Queue Panel */}
1120
+ {/* Decision Queue */}
3269
1121
  {isDecisionQueueOpen && (
3270
1122
  <div className="fixed left-4 bottom-4 w-[400px] max-h-[500px] z-50 shadow-modal">
3271
1123
  <div className="relative">
@@ -3289,7 +1141,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3289
1141
  </div>
3290
1142
  )}
3291
1143
 
3292
- {/* Decision Queue Toggle Button (bottom-left when panel is closed) */}
3293
1144
  {!isDecisionQueueOpen && decisions.length > 0 && (
3294
1145
  <button
3295
1146
  onClick={() => setIsDecisionQueueOpen(true)}
@@ -3309,58 +1160,35 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3309
1160
  </button>
3310
1161
  )}
3311
1162
 
3312
- {/* User Profile Panel */}
3313
1163
  <UserProfilePanel
3314
1164
  user={selectedUserProfile}
3315
1165
  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
- }}
1166
+ onMention={(username) => { setPendingMention(username); setSelectedUserProfile(null); }}
1167
+ onSendMessage={(user) => { setCurrentChannel(user.username); markDmSeen(user.username); setSelectedUserProfile(null); }}
3326
1168
  />
3327
1169
 
3328
- {/* Agent Profile Panel */}
3329
1170
  <AgentProfilePanel
3330
1171
  agent={selectedAgentProfile}
3331
1172
  onClose={() => setSelectedAgentProfile(null)}
3332
- onMessage={(agent) => {
3333
- selectAgent(agent.name);
3334
- setCurrentChannel(agent.name);
3335
- setSelectedAgentProfile(null);
3336
- }}
1173
+ onMessage={(agent) => { selectAgent(agent.name); setCurrentChannel(agent.name); setSelectedAgentProfile(null); }}
3337
1174
  onLogs={handleLogsClick}
3338
1175
  onRelease={handleReleaseAgent}
3339
1176
  summary={selectedAgentProfile ? agentSummariesMap.get(selectedAgentProfile.name.toLowerCase()) : null}
3340
1177
  />
3341
1178
 
3342
- {/* Coordinator Panel */}
3343
1179
  <CoordinatorPanel
3344
1180
  isOpen={isCoordinatorOpen}
3345
1181
  onClose={() => setIsCoordinatorOpen(false)}
3346
1182
  projects={mergedProjects}
3347
- isCloudMode={!!currentUser}
3348
1183
  hasArchitect={bridgeAgents.some(a => a.name.toLowerCase() === 'architect')}
3349
- onArchitectSpawned={() => {
3350
- // Architect will appear via WebSocket update
3351
- setIsCoordinatorOpen(false);
3352
- }}
1184
+ onArchitectSpawned={() => setIsCoordinatorOpen(false)}
3353
1185
  />
3354
1186
 
3355
- {/* Full Settings Page */}
3356
1187
  {isFullSettingsOpen && (
3357
1188
  <SettingsPage
3358
- currentUserId={cloudSession?.user?.id}
1189
+ currentUserId={cloudUser?.id}
3359
1190
  initialTab={settingsInitialTab}
3360
- onClose={() => {
3361
- setIsFullSettingsOpen(false);
3362
- urlCloseSettings();
3363
- }}
1191
+ onClose={() => { setIsFullSettingsOpen(false); urlCloseSettings(); }}
3364
1192
  settings={settings}
3365
1193
  onUpdateSettings={updateSettings}
3366
1194
  activeWorkspaceId={effectiveActiveWorkspaceId}
@@ -3368,30 +1196,19 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
3368
1196
  />
3369
1197
  )}
3370
1198
 
3371
- {/* Toast Notifications */}
3372
- <NotificationToast
3373
- toasts={toasts}
3374
- onDismiss={dismissToast}
3375
- position="top-right"
3376
- />
1199
+ <NotificationToast toasts={toasts} onDismiss={dismissToast} position="top-right" />
3377
1200
  </div>
3378
- </WorkspaceProvider>
3379
1201
  );
3380
1202
  }
3381
1203
 
1204
+ // ---------------------------------------------------------------------------
1205
+ // Small presentation components
1206
+ // ---------------------------------------------------------------------------
1207
+
3382
1208
  function LoadingSpinner() {
3383
1209
  return (
3384
1210
  <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
- />
1211
+ <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" strokeDasharray="32" strokeLinecap="round" />
3395
1212
  </svg>
3396
1213
  );
3397
1214
  }