@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.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/1028-da5d75e35d1420f1.js +1 -0
- package/out/_next/static/chunks/1528-78b17000a7e10bc6.js +2 -0
- package/out/_next/static/chunks/1695-4a5d33ba715e09b4.js +1 -0
- package/out/_next/static/chunks/1705-36c2180d00a4a569.js +1 -0
- package/out/_next/static/chunks/1dd3208c-e1f87c7b3dc1a820.js +1 -0
- package/out/_next/static/chunks/3663-47290254b8f6f5dd.js +1 -0
- package/out/_next/static/chunks/3677-4b225baf4801d9b9.js +73 -0
- package/out/_next/static/chunks/5118-7e8ada2df38eef07.js +1 -0
- package/out/_next/static/chunks/5888-15cbe97c90ed5fae.js +1 -0
- package/out/_next/static/chunks/6773-a45343a98df3abb5.js +1 -0
- package/out/_next/static/chunks/6940-b824612b605e79b3.js +9 -0
- package/out/_next/static/chunks/7894-f4a15249082a680d.js +1 -0
- package/out/_next/static/chunks/9175-b3617c1e5cbfed0e.js +1 -0
- package/out/_next/static/chunks/9372-1a804b8d08c7a236.js +1 -0
- package/out/_next/static/chunks/{ab6c8a12-0a58072fbb505134.js → ab6c8a12-91438a812d94ecf0.js} +1 -1
- package/out/_next/static/chunks/app/_not-found/page-8e8842f82d204726.js +1 -0
- package/out/_next/static/chunks/app/about/page-b78577a7da8fa459.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/page-3dffd65b6344f53e.js +1 -0
- package/out/_next/static/chunks/app/app/onboarding/page-b89be9aa6264a5e1.js +1 -0
- package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-fbd00893ef69e499.js +1 -0
- package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-de2ea13649d0b6d3.js +1 -0
- package/out/_next/static/chunks/app/blog/page-a08e263c57a156fa.js +1 -0
- package/out/_next/static/chunks/app/careers/page-02228e1d6969b232.js +1 -0
- package/out/_next/static/chunks/app/changelog/page-1b5c1d79efc6e53a.js +1 -0
- package/out/_next/static/chunks/app/cloud/link/page-99654edffffb3af2.js +1 -0
- package/out/_next/static/chunks/app/complete-profile/page-59d146e5ddeafc5c.js +1 -0
- package/out/_next/static/chunks/app/connect-repos/page-995e16a976a6632c.js +1 -0
- package/out/_next/static/chunks/app/contact/page-273396a5ad57bcee.js +1 -0
- package/out/_next/static/chunks/app/dev/cli-tools/page-a71b80dcb2d5fc8d.js +1 -0
- package/out/_next/static/chunks/app/dev/log-viewer/page-46a6151ae1be0796.js +1 -0
- package/out/_next/static/chunks/app/docs/page-7c7cb603b24b7c40.js +1 -0
- package/out/_next/static/chunks/app/history/page-0c5cab1dab4e8886.js +1 -0
- package/out/_next/static/chunks/app/layout-96d72ba8ef8a43a0.js +1 -0
- package/out/_next/static/chunks/app/login/page-0ccbab34213df842.js +1 -0
- package/out/_next/static/chunks/app/metrics/page-8616272aeab9c8b0.js +1 -0
- package/out/_next/static/chunks/app/page-09ce10603ad9a251.js +1 -0
- package/out/_next/static/chunks/app/pricing/page-91c975079120c941.js +1 -0
- package/out/_next/static/chunks/app/privacy/{page-c21d51ac2dee3a88.js → page-a49ab271cc686644.js} +1 -1
- package/out/_next/static/chunks/app/providers/{page-59114505f4353512.js → page-d775d6eb5bc29e96.js} +1 -1
- package/out/_next/static/chunks/app/providers/setup/[provider]/page-ec4ef3cd80de807e.js +1 -0
- package/out/_next/static/chunks/app/security/page-d9da9bd9191e8f95.js +1 -0
- package/out/_next/static/chunks/app/signup/page-930eca0bf5fd299d.js +1 -0
- package/out/_next/static/chunks/app/terms/page-3e4827620b98613c.js +1 -0
- package/out/_next/static/chunks/framework-648e1ae7da590300.js +1 -0
- package/out/_next/static/chunks/{main-acb1b24265295d6a.js → main-2b1990080c292d92.js} +1 -1
- package/out/_next/static/chunks/main-app-9f6b7ff9e754a8f5.js +1 -0
- package/out/_next/static/chunks/pages/_app-a077b72e02273ab1.js +1 -0
- package/out/_next/static/chunks/pages/_error-84001666436a04e4.js +1 -0
- package/out/_next/static/chunks/{webpack-dd93b81e2659669c.js → webpack-7586035f1585f2db.js} +1 -1
- package/out/_next/static/css/eb9fc69d1e3d2bed.css +1 -0
- package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_buildManifest.js +1 -1
- package/out/about.html +2 -2
- package/out/about.txt +2 -2
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +2 -2
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +3 -3
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +2 -2
- package/out/changelog.html +2 -2
- package/out/changelog.txt +2 -2
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +2 -2
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +2 -2
- package/out/contact.html +2 -2
- package/out/contact.txt +2 -2
- package/out/dev/cli-tools.html +1 -0
- package/out/dev/cli-tools.txt +7 -0
- package/out/dev/log-viewer.html +23 -0
- package/out/dev/log-viewer.txt +7 -0
- package/out/docs.html +2 -2
- package/out/docs.txt +2 -2
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +2 -2
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +2 -2
- package/out/privacy.html +2 -2
- package/out/privacy.txt +2 -2
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +2 -2
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +2 -2
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +2 -2
- package/out/providers.html +1 -1
- package/out/providers.txt +2 -2
- package/out/security.html +2 -2
- package/out/security.txt +2 -2
- package/out/signup.html +2 -2
- package/out/signup.txt +2 -2
- package/out/terms.html +2 -2
- package/out/terms.txt +2 -2
- package/package.json +5 -1
- package/src/adapters/DashboardConfigProvider.tsx +56 -0
- package/src/adapters/cloudFetchAdapter.ts +278 -0
- package/src/adapters/index.ts +3 -0
- package/src/adapters/types.ts +508 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +67 -18
- package/src/app/app/onboarding/page.tsx +870 -170
- package/src/app/cloud/link/page.tsx +14 -6
- package/src/app/connect-repos/page.tsx +9 -3
- package/src/app/dev/cli-tools/page.tsx +130 -0
- package/src/app/dev/log-viewer/MockLogViewer.tsx +132 -0
- package/src/app/dev/log-viewer/fixtures.ts +110 -0
- package/src/app/dev/log-viewer/page.tsx +288 -0
- package/src/app/history/page.tsx +28 -12
- package/src/app/page.tsx +1 -1
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +209 -59
- package/src/components/AgentCard.tsx +4 -4
- package/src/components/AgentLogPreview.tsx +2 -38
- package/src/components/App.tsx +441 -2624
- package/src/components/CliToolHarness.test.tsx +83 -0
- package/src/components/CliToolHarness.tsx +292 -0
- package/src/components/CoordinatorPanel.tsx +13 -6
- package/src/components/LogViewer.tsx +2 -42
- package/src/components/ProviderAuthFlow.tsx +201 -81
- package/src/components/ProvisioningProgress.tsx +1 -1
- package/src/components/ReactionChips.tsx +2 -1
- package/src/components/SpawnModal.test.tsx +51 -18
- package/src/components/SpawnModal.tsx +175 -207
- package/src/components/TerminalProviderSetup.tsx +1 -1
- package/src/components/ThreadPanel.tsx +2 -0
- package/src/components/WorkspaceContext.tsx +7 -19
- package/src/components/XTermLogViewer.tsx +190 -27
- package/src/components/channels/ChannelMessageList.tsx +94 -4
- package/src/components/channels/ChannelViewV1.tsx +35 -11
- package/src/components/channels/api.ts +21 -20
- package/src/components/channels/types.ts +16 -0
- package/src/components/hooks/index.ts +0 -19
- package/src/components/hooks/useMessages.test.ts +80 -0
- package/src/components/hooks/useMessages.ts +13 -4
- package/src/components/hooks/useOrchestrator.ts +1 -1
- package/src/components/hooks/usePresence.ts +45 -6
- package/src/components/hooks/useThread.ts +83 -46
- package/src/components/hooks/useTrajectory.ts +62 -5
- package/src/components/hooks/useWebSocket.test.ts +358 -0
- package/src/components/hooks/useWebSocket.ts +243 -5
- package/src/components/index.ts +2 -14
- package/src/components/layout/Header.tsx +9 -15
- package/src/components/layout/Sidebar.tsx +1 -8
- package/src/components/settings/SettingsPage.tsx +108 -47
- package/src/components/settings/index.ts +0 -3
- package/src/landing/blogData.ts +1 -1
- package/src/lib/agent-merge.test.ts +2 -2
- package/src/lib/api.ts +8 -38
- package/src/lib/identity.test.ts +139 -0
- package/src/lib/identity.ts +48 -0
- package/src/lib/relaycastMessageAdapters.test.ts +182 -0
- package/src/lib/relaycastMessageAdapters.ts +105 -0
- package/src/lib/sanitize-logs.test.ts +227 -0
- package/src/lib/sanitize-logs.ts +202 -0
- package/src/providers/AgentProvider.tsx +799 -0
- package/src/providers/ChannelProvider.tsx +528 -0
- package/src/providers/CloudWorkspaceProvider.tsx +402 -0
- package/src/providers/MessageProvider.tsx +875 -0
- package/src/providers/RelayConfigProvider.tsx +94 -0
- package/src/providers/SendProvider.tsx +497 -0
- package/src/providers/SettingsProvider.tsx +247 -0
- package/src/providers/index.ts +26 -0
- package/src/types/index.ts +10 -10
- package/out/_next/static/chunks/11-9a2993a37266dcb3.js +0 -9
- package/out/_next/static/chunks/118-ae2b650136a5a5fc.js +0 -1
- package/out/_next/static/chunks/1dd3208c-40ab0fc0f60392b8.js +0 -1
- package/out/_next/static/chunks/202-fc0763dd7488e58f.js +0 -1
- package/out/_next/static/chunks/259-83b77fa1b91ba5aa.js +0 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +0 -1
- package/out/_next/static/chunks/528-f5f676996d613c25.js +0 -2
- package/out/_next/static/chunks/663-ddb04081febc3678.js +0 -1
- package/out/_next/static/chunks/687-88b6b139a6bb0e2e.js +0 -1
- package/out/_next/static/chunks/695-51d25b1988644374.js +0 -1
- package/out/_next/static/chunks/773-54a2641043c81e55.js +0 -1
- package/out/_next/static/chunks/app/_not-found/page-6da9b72091e5b511.js +0 -1
- package/out/_next/static/chunks/app/about/page-fff7c6457683f243.js +0 -1
- package/out/_next/static/chunks/app/app/[[...slug]]/page-f7eca1b66fb4249b.js +0 -1
- package/out/_next/static/chunks/app/app/onboarding/page-129abc5da2e67971.js +0 -1
- package/out/_next/static/chunks/app/blog/go-to-bed-wake-up-to-a-finished-product/page-5d5f28fd126b692f.js +0 -1
- package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/page-b194f207fbd91862.js +0 -1
- package/out/_next/static/chunks/app/blog/page-b9bd9d8703fca76a.js +0 -1
- package/out/_next/static/chunks/app/careers/page-a4bd8d5f4de8f4eb.js +0 -1
- package/out/_next/static/chunks/app/changelog/page-9a1f6ad1743d63c5.js +0 -1
- package/out/_next/static/chunks/app/cloud/link/page-0844c5699b027c3b.js +0 -1
- package/out/_next/static/chunks/app/complete-profile/page-39ed5a67916beb87.js +0 -1
- package/out/_next/static/chunks/app/connect-repos/page-297eddee0c39f2a3.js +0 -1
- package/out/_next/static/chunks/app/contact/page-3c1dd8690217fade.js +0 -1
- package/out/_next/static/chunks/app/docs/page-1875e981f2c3fd13.js +0 -1
- package/out/_next/static/chunks/app/history/page-2d5c5695c9e8b40c.js +0 -1
- package/out/_next/static/chunks/app/layout-0a4b99656da25511.js +0 -1
- package/out/_next/static/chunks/app/login/page-f69c076f5a6fc520.js +0 -1
- package/out/_next/static/chunks/app/metrics/page-bebbee055669a17e.js +0 -1
- package/out/_next/static/chunks/app/page-0ee604f7070d14c0.js +0 -1
- package/out/_next/static/chunks/app/pricing/page-eeae7d594af333b6.js +0 -1
- package/out/_next/static/chunks/app/providers/setup/[provider]/page-daf9b3e05e77ae19.js +0 -1
- package/out/_next/static/chunks/app/security/page-cd562730fe84a0a2.js +0 -1
- package/out/_next/static/chunks/app/signup/page-c242ca08101a84ff.js +0 -1
- package/out/_next/static/chunks/app/terms/page-c7001720e7941dc6.js +0 -1
- package/out/_next/static/chunks/framework-3664cab31236a9fa.js +0 -1
- package/out/_next/static/chunks/main-app-7f73a939a312a228.js +0 -1
- package/out/_next/static/chunks/pages/_app-10a93ab5b7c32eb3.js +0 -1
- package/out/_next/static/chunks/pages/_error-2d792b2a41857be4.js +0 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +0 -1
- package/src/components/BillingResult.tsx +0 -447
- package/src/components/CloudSessionProvider.tsx +0 -130
- package/src/components/SessionExpiredModal.tsx +0 -128
- package/src/components/WorkspaceStatusIndicator.tsx +0 -396
- package/src/components/hooks/useSession.ts +0 -209
- package/src/components/hooks/useWorkspaceMembers.ts +0 -132
- package/src/components/hooks/useWorkspaceStatus.ts +0 -237
- package/src/components/settings/BillingSettingsPanel.tsx +0 -564
- package/src/components/settings/TeamSettingsPanel.tsx +0 -560
- package/src/components/settings/WorkspaceSettingsPanel.tsx +0 -1368
- package/src/lib/cloudApi.ts +0 -893
- /package/out/_next/static/{IxfA6RZu4trcsEMYlkQra → g3G0LMdB7lxcrU5mdM54m}/_ssgManifest.js +0 -0
package/src/components/App.tsx
CHANGED
|
@@ -1,203 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dashboard V2 - Main Application Component
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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,
|
|
16
|
-
import { SpawnModal
|
|
16
|
+
import { CommandPalette, PRIORITY_CONFIG } from './CommandPalette';
|
|
17
|
+
import { SpawnModal } from './SpawnModal';
|
|
17
18
|
import { NewConversationModal } from './NewConversationModal';
|
|
18
|
-
import { SettingsPage
|
|
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
|
|
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
|
|
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 {
|
|
45
|
-
import {
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
})
|
|
105
|
+
})();
|
|
241
106
|
return () => { cancelled = true; };
|
|
242
107
|
}
|
|
243
108
|
}, [wsError, wsData, restData]);
|
|
244
109
|
|
|
245
|
-
|
|
246
|
-
const
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
const [
|
|
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
|
|
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
|
-
//
|
|
862
|
-
|
|
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
|
-
//
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1222
|
-
if (
|
|
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
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1581
|
-
const handleProjectSelect = useCallback((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
|
-
|
|
1591
|
-
|
|
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,
|
|
403
|
+
}, [selectAgent, setCurrentChannel, closeSidebarOnMobile, orchestratorWorkspaces.length, addRecentRepo, setViewMode, setSelectedChannelId, setCurrentProject, cwSwitchWorkspace]);
|
|
1603
404
|
|
|
1604
|
-
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
408
|
+
setCurrentChannel(human.name);
|
|
409
|
+
markDmSeen(human.name);
|
|
410
|
+
navigateToDm(human.name);
|
|
1612
411
|
closeSidebarOnMobile();
|
|
1613
|
-
}, [
|
|
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
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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
|
-
|
|
1851
|
-
|
|
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
|
-
|
|
475
|
+
setAddWorkspaceError(err instanceof Error ? err.message : 'Failed to add workspace');
|
|
476
|
+
throw err;
|
|
1877
477
|
} finally {
|
|
1878
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
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
|
-
|
|
2228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2684
|
-
|
|
2685
|
-
//
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
2820
|
-
channelsList.find(
|
|
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
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
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 ? '
|
|
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
|
-
|
|
3001
|
-
|
|
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 (
|
|
3018
|
-
|
|
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
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
973
|
+
{/* Modals & Overlays */}
|
|
3104
974
|
<CommandPalette
|
|
3105
975
|
isOpen={isCommandPaletteOpen}
|
|
3106
|
-
onClose={
|
|
976
|
+
onClose={() => setIsCommandPaletteOpen(false)}
|
|
3107
977
|
agents={agents}
|
|
3108
|
-
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
}
|