@agent-relay/dashboard 2.0.81 → 2.0.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- 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 +2 -2
- 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 +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- 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 +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- 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 +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDirectMessage Hook
|
|
3
|
+
*
|
|
4
|
+
* Handles DM conversation logic including agent participation,
|
|
5
|
+
* message filtering, and deduplication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo } from 'react';
|
|
9
|
+
import type { Agent, Message } from '../../types';
|
|
10
|
+
|
|
11
|
+
export interface UseDirectMessageOptions {
|
|
12
|
+
currentHuman: Agent | null;
|
|
13
|
+
currentUserName: string | null;
|
|
14
|
+
messages: Message[];
|
|
15
|
+
agents: Agent[];
|
|
16
|
+
selectedDmAgents: string[];
|
|
17
|
+
removedDmAgents: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseDirectMessageResult {
|
|
21
|
+
visibleMessages: Message[];
|
|
22
|
+
participantAgents: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useDirectMessage({
|
|
26
|
+
currentHuman,
|
|
27
|
+
currentUserName,
|
|
28
|
+
messages,
|
|
29
|
+
agents,
|
|
30
|
+
selectedDmAgents,
|
|
31
|
+
removedDmAgents,
|
|
32
|
+
}: UseDirectMessageOptions): UseDirectMessageResult {
|
|
33
|
+
const agentNameSet = useMemo(() => new Set(agents.map((a) => a.name)), [agents]);
|
|
34
|
+
|
|
35
|
+
// Derive agents participating in this conversation from message history
|
|
36
|
+
const dmParticipantAgents = useMemo(() => {
|
|
37
|
+
if (!currentHuman) return [];
|
|
38
|
+
const humanName = currentHuman.name;
|
|
39
|
+
const derived = new Set<string>();
|
|
40
|
+
|
|
41
|
+
for (const msg of messages) {
|
|
42
|
+
const { from, to } = msg;
|
|
43
|
+
if (!from || !to) continue;
|
|
44
|
+
if (from === humanName && agentNameSet.has(to)) derived.add(to);
|
|
45
|
+
if (to === humanName && agentNameSet.has(from)) derived.add(from);
|
|
46
|
+
if (selectedDmAgents.includes(from) && agentNameSet.has(to)) derived.add(to);
|
|
47
|
+
if (selectedDmAgents.includes(to) && agentNameSet.has(from)) derived.add(from);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const participants = new Set<string>([...selectedDmAgents, ...derived]);
|
|
51
|
+
removedDmAgents.forEach((a) => participants.delete(a));
|
|
52
|
+
return Array.from(participants);
|
|
53
|
+
}, [agentNameSet, currentHuman, messages, removedDmAgents, selectedDmAgents]);
|
|
54
|
+
|
|
55
|
+
// Filter messages for this DM conversation
|
|
56
|
+
const visibleMessages = useMemo(() => {
|
|
57
|
+
if (!currentHuman) return messages;
|
|
58
|
+
// Include current user, the other human, and all participant agents
|
|
59
|
+
const participants = new Set<string>([currentHuman.name, ...dmParticipantAgents]);
|
|
60
|
+
// Add current user to participants - use "Dashboard" as fallback for local mode
|
|
61
|
+
const effectiveUserName = currentUserName || 'Dashboard';
|
|
62
|
+
participants.add(effectiveUserName);
|
|
63
|
+
|
|
64
|
+
console.log('[DM Filter] currentHuman:', currentHuman.name, 'currentUser:', currentUserName, 'agents:', dmParticipantAgents, 'participants:', Array.from(participants));
|
|
65
|
+
|
|
66
|
+
const filtered = messages.filter((msg) => {
|
|
67
|
+
if (!msg.from || !msg.to) return false;
|
|
68
|
+
const hasFrom = participants.has(msg.from);
|
|
69
|
+
const hasTo = participants.has(msg.to);
|
|
70
|
+
const passes = hasFrom && hasTo;
|
|
71
|
+
|
|
72
|
+
if (msg.from?.includes('Agent') || msg.to?.includes('Agent')) {
|
|
73
|
+
console.log('[DM Filter] msg:', msg.from, '->', msg.to, 'hasFrom:', hasFrom, 'hasTo:', hasTo, 'passes:', passes);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return passes;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log('[DM Filter] filtered count:', filtered.length);
|
|
80
|
+
return filtered;
|
|
81
|
+
}, [currentHuman, currentUserName, dmParticipantAgents, messages]);
|
|
82
|
+
|
|
83
|
+
// Deduplicate DM messages (merge duplicates sent to multiple participants)
|
|
84
|
+
const dedupedVisibleMessages = useMemo(() => {
|
|
85
|
+
if (!currentHuman) return visibleMessages;
|
|
86
|
+
|
|
87
|
+
const normalizeBody = (content?: string) => (content ?? '').trim().replace(/\s+/g, ' ');
|
|
88
|
+
const rank = (msg: Message) => (msg.status === 'sending' ? 1 : 0);
|
|
89
|
+
const choose = (current: Message, incoming: Message) => {
|
|
90
|
+
const currentRank = rank(current);
|
|
91
|
+
const incomingRank = rank(incoming);
|
|
92
|
+
const currentTs = new Date(current.timestamp).getTime();
|
|
93
|
+
const incomingTs = new Date(incoming.timestamp).getTime();
|
|
94
|
+
if (incomingRank < currentRank) return incoming;
|
|
95
|
+
if (incomingRank > currentRank) return current;
|
|
96
|
+
return incomingTs >= currentTs ? incoming : current;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const sorted = [...visibleMessages].sort(
|
|
100
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const byId = new Map<string, Message>();
|
|
104
|
+
const byFuzzy = new Map<string, Message>();
|
|
105
|
+
|
|
106
|
+
for (const msg of sorted) {
|
|
107
|
+
if (msg.id) {
|
|
108
|
+
const existing = byId.get(msg.id);
|
|
109
|
+
byId.set(msg.id, existing ? choose(existing, msg) : msg);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sender = msg.from?.toLowerCase() ?? '';
|
|
114
|
+
const bucket = Math.floor(new Date(msg.timestamp).getTime() / 5000);
|
|
115
|
+
const key = `${sender}|${bucket}|${normalizeBody(msg.content)}`;
|
|
116
|
+
const existing = byFuzzy.get(key);
|
|
117
|
+
byFuzzy.set(key, existing ? choose(existing, msg) : msg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const merged = [...byId.values(), ...byFuzzy.values()];
|
|
121
|
+
|
|
122
|
+
// Final pass: deduplicate by sender + recipient + content (no time bucket)
|
|
123
|
+
const finalDedup = new Map<string, Message>();
|
|
124
|
+
for (const msg of merged) {
|
|
125
|
+
const sender = msg.from?.toLowerCase() ?? '';
|
|
126
|
+
const recipient = msg.to?.toLowerCase() ?? '';
|
|
127
|
+
const key = `${sender}|${recipient}|${normalizeBody(msg.content)}`;
|
|
128
|
+
const existing = finalDedup.get(key);
|
|
129
|
+
finalDedup.set(key, existing ? choose(existing, msg) : msg);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Array.from(finalDedup.values()).sort(
|
|
133
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
134
|
+
);
|
|
135
|
+
}, [currentHuman, visibleMessages]);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
visibleMessages: dedupedVisibleMessages,
|
|
139
|
+
participantAgents: dmParticipantAgents,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMessages Hook
|
|
3
|
+
*
|
|
4
|
+
* React hook for managing message state with filtering,
|
|
5
|
+
* threading, and send functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
9
|
+
import type { Message, SendMessageRequest } from '../../types';
|
|
10
|
+
import { api } from '../../lib/api';
|
|
11
|
+
|
|
12
|
+
export interface UseMessagesOptions {
|
|
13
|
+
messages: Message[];
|
|
14
|
+
currentChannel?: string;
|
|
15
|
+
/** Optional sender name for cloud mode (GitHub username). Falls back to 'Dashboard' if not provided. */
|
|
16
|
+
senderName?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThreadInfo {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
lastMessage: Message;
|
|
23
|
+
messageCount: number;
|
|
24
|
+
unreadCount: number;
|
|
25
|
+
participants: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseMessagesReturn {
|
|
29
|
+
// Filtered messages
|
|
30
|
+
messages: Message[];
|
|
31
|
+
threadMessages: (threadId: string) => Message[];
|
|
32
|
+
|
|
33
|
+
// Channel/thread state
|
|
34
|
+
currentChannel: string;
|
|
35
|
+
setCurrentChannel: (channel: string) => void;
|
|
36
|
+
currentThread: string | null;
|
|
37
|
+
setCurrentThread: (threadId: string | null) => void;
|
|
38
|
+
|
|
39
|
+
// Thread info
|
|
40
|
+
activeThreads: ThreadInfo[];
|
|
41
|
+
totalUnreadThreadCount: number;
|
|
42
|
+
|
|
43
|
+
// Message actions
|
|
44
|
+
sendMessage: (to: string, content: string, thread?: string, attachmentIds?: string[]) => Promise<boolean>;
|
|
45
|
+
isSending: boolean;
|
|
46
|
+
sendError: string | null;
|
|
47
|
+
|
|
48
|
+
// Stats
|
|
49
|
+
totalCount: number;
|
|
50
|
+
unreadCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useMessages({
|
|
54
|
+
messages,
|
|
55
|
+
currentChannel: initialChannel = 'general',
|
|
56
|
+
senderName,
|
|
57
|
+
}: UseMessagesOptions): UseMessagesReturn {
|
|
58
|
+
const [currentChannel, setCurrentChannel] = useState(initialChannel);
|
|
59
|
+
const [currentThreadInternal, setCurrentThreadInternal] = useState<string | null>(null);
|
|
60
|
+
const [isSending, setIsSending] = useState(false);
|
|
61
|
+
const [sendError, setSendError] = useState<string | null>(null);
|
|
62
|
+
|
|
63
|
+
// Track seen threads with timestamp of when they were last viewed
|
|
64
|
+
// This allows us to show new messages that arrive after viewing
|
|
65
|
+
const [seenThreads, setSeenThreads] = useState<Map<string, number>>(new Map());
|
|
66
|
+
|
|
67
|
+
// Effective sender name for the current user (used for filtering own messages)
|
|
68
|
+
const effectiveSenderName = senderName || 'Dashboard';
|
|
69
|
+
|
|
70
|
+
// Optimistic messages: shown immediately before server confirms
|
|
71
|
+
// These have status='sending' and a temp ID prefixed with 'optimistic-'
|
|
72
|
+
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
|
73
|
+
|
|
74
|
+
// Clean up optimistic messages when they appear in the real messages list
|
|
75
|
+
// Match by to + content only (not from, since server may use different sender like 'Dashboard')
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (optimisticMessages.length === 0) return;
|
|
78
|
+
|
|
79
|
+
// Create a set of "fingerprints" for real messages (recent ones only)
|
|
80
|
+
// Use to + content only - the 'from' field may differ between optimistic (user's name)
|
|
81
|
+
// and real message (server may use 'Dashboard' as relay client name)
|
|
82
|
+
const recentMessages = messages.slice(-50); // Only check recent messages for performance
|
|
83
|
+
const realFingerprints = new Set(
|
|
84
|
+
recentMessages.map((m) => `${m.to}:${m.content.slice(0, 100)}`)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Remove optimistic messages that now exist in real messages
|
|
88
|
+
setOptimisticMessages((prev) =>
|
|
89
|
+
prev.filter((opt) => {
|
|
90
|
+
const fingerprint = `${opt.to}:${opt.content.slice(0, 100)}`;
|
|
91
|
+
return !realFingerprints.has(fingerprint);
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}, [messages, optimisticMessages.length]);
|
|
95
|
+
|
|
96
|
+
// Combine real messages with optimistic messages, sorted by timestamp
|
|
97
|
+
const allMessages = useMemo(() => {
|
|
98
|
+
if (optimisticMessages.length === 0) return messages;
|
|
99
|
+
return [...messages, ...optimisticMessages].sort(
|
|
100
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
101
|
+
);
|
|
102
|
+
}, [messages, optimisticMessages]);
|
|
103
|
+
|
|
104
|
+
// Filter messages by current channel
|
|
105
|
+
// Only exclude reply-chain replies (where thread is another message's ID)
|
|
106
|
+
// Keep topic thread messages (where thread is a topic name, not a message ID)
|
|
107
|
+
const filteredMessages = useMemo(() => {
|
|
108
|
+
// Build set of message IDs for efficient lookup
|
|
109
|
+
const messageIds = new Set(allMessages.map((m) => m.id));
|
|
110
|
+
|
|
111
|
+
// Filter out reply-chain replies (thread points to existing message ID)
|
|
112
|
+
// Keep topic thread messages (thread is a name, not a message ID)
|
|
113
|
+
const mainViewMessages = allMessages.filter((m) => {
|
|
114
|
+
if (!m.thread) return true; // No thread - show it
|
|
115
|
+
// If thread is a message ID, it's a reply - hide it from main view
|
|
116
|
+
// If thread is a topic name, show it
|
|
117
|
+
return !messageIds.has(m.thread);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (currentChannel === 'general') {
|
|
121
|
+
return mainViewMessages;
|
|
122
|
+
}
|
|
123
|
+
return mainViewMessages.filter(
|
|
124
|
+
(m) => m.from === currentChannel || m.to === currentChannel
|
|
125
|
+
);
|
|
126
|
+
}, [allMessages, currentChannel]);
|
|
127
|
+
|
|
128
|
+
// Get messages for a specific thread
|
|
129
|
+
const threadMessages = useCallback(
|
|
130
|
+
(threadId: string) => allMessages.filter((m) => m.thread === threadId),
|
|
131
|
+
[allMessages]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Calculate active threads with unread counts
|
|
135
|
+
const activeThreads = useMemo((): ThreadInfo[] => {
|
|
136
|
+
const threadMap = new Map<string, Message[]>();
|
|
137
|
+
const messageIds = new Set(allMessages.map((m) => m.id));
|
|
138
|
+
|
|
139
|
+
// Group messages by thread
|
|
140
|
+
for (const msg of allMessages) {
|
|
141
|
+
if (msg.thread) {
|
|
142
|
+
const existing = threadMap.get(msg.thread) || [];
|
|
143
|
+
existing.push(msg);
|
|
144
|
+
threadMap.set(msg.thread, existing);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Convert to ThreadInfo array
|
|
149
|
+
const threads: ThreadInfo[] = [];
|
|
150
|
+
for (const [threadId, threadMsgs] of threadMap.entries()) {
|
|
151
|
+
// Sort by timestamp to get the last message
|
|
152
|
+
const sorted = [...threadMsgs].sort(
|
|
153
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Get unique participants
|
|
157
|
+
const participants = [...new Set(threadMsgs.flatMap((m) => [m.from, m.to]))].filter(
|
|
158
|
+
(p) => p !== '*'
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Count unread messages in thread
|
|
162
|
+
// Consider messages as "read" if they arrived before we last viewed this thread
|
|
163
|
+
// Exclude messages from current user - users shouldn't get notifications for their own messages
|
|
164
|
+
const seenTimestamp = seenThreads.get(threadId);
|
|
165
|
+
const unreadCount = threadMsgs.filter((m) => {
|
|
166
|
+
if (m.from === effectiveSenderName) return false; // Don't count own messages as unread
|
|
167
|
+
if (m.isRead) return false; // Already marked as read
|
|
168
|
+
if (seenTimestamp) {
|
|
169
|
+
// If we've seen this thread, only count messages after that time
|
|
170
|
+
return new Date(m.timestamp).getTime() > seenTimestamp;
|
|
171
|
+
}
|
|
172
|
+
return true; // Not seen yet, count as unread
|
|
173
|
+
}).length;
|
|
174
|
+
|
|
175
|
+
// Determine thread name: if threadId is a message ID, use first message content as name
|
|
176
|
+
let name = threadId;
|
|
177
|
+
if (messageIds.has(threadId)) {
|
|
178
|
+
// Find the original message that started the thread
|
|
179
|
+
const originalMsg = allMessages.find((m) => m.id === threadId);
|
|
180
|
+
if (originalMsg) {
|
|
181
|
+
// Use first line of content, truncated
|
|
182
|
+
const firstLine = originalMsg.content.split('\n')[0];
|
|
183
|
+
name = firstLine.length > 30 ? firstLine.substring(0, 30) + '...' : firstLine;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
threads.push({
|
|
188
|
+
id: threadId,
|
|
189
|
+
name,
|
|
190
|
+
lastMessage: sorted[0],
|
|
191
|
+
messageCount: threadMsgs.length,
|
|
192
|
+
unreadCount,
|
|
193
|
+
participants,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sort by last activity (most recent first)
|
|
198
|
+
return threads.sort(
|
|
199
|
+
(a, b) =>
|
|
200
|
+
new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
|
201
|
+
);
|
|
202
|
+
}, [allMessages, seenThreads]);
|
|
203
|
+
|
|
204
|
+
// Wrapper for setCurrentThread that also marks the thread as seen
|
|
205
|
+
const setCurrentThread = useCallback((threadId: string | null) => {
|
|
206
|
+
setCurrentThreadInternal(threadId);
|
|
207
|
+
if (threadId) {
|
|
208
|
+
// Mark thread as seen with current timestamp
|
|
209
|
+
setSeenThreads((prev) => {
|
|
210
|
+
const next = new Map(prev);
|
|
211
|
+
next.set(threadId, Date.now());
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
// Calculate total unread threads
|
|
218
|
+
const totalUnreadThreadCount = useMemo(
|
|
219
|
+
() => activeThreads.filter((t) => t.unreadCount > 0).length,
|
|
220
|
+
[activeThreads]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Calculate stats
|
|
224
|
+
const stats = useMemo(() => {
|
|
225
|
+
const unread = allMessages.filter((m) => !m.isRead).length;
|
|
226
|
+
return {
|
|
227
|
+
totalCount: allMessages.length,
|
|
228
|
+
unreadCount: unread,
|
|
229
|
+
};
|
|
230
|
+
}, [allMessages]);
|
|
231
|
+
|
|
232
|
+
// Send message function with optimistic updates
|
|
233
|
+
const sendMessage = useCallback(
|
|
234
|
+
async (to: string, content: string, thread?: string, attachmentIds?: string[]): Promise<boolean> => {
|
|
235
|
+
setIsSending(true);
|
|
236
|
+
setSendError(null);
|
|
237
|
+
|
|
238
|
+
// Create optimistic message and add it immediately for snappy UX
|
|
239
|
+
const from = effectiveSenderName;
|
|
240
|
+
const optimisticId = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
241
|
+
const optimisticMsg: Message = {
|
|
242
|
+
id: optimisticId,
|
|
243
|
+
from,
|
|
244
|
+
to,
|
|
245
|
+
content,
|
|
246
|
+
timestamp: new Date().toISOString(),
|
|
247
|
+
status: 'sending',
|
|
248
|
+
thread,
|
|
249
|
+
isRead: true, // User's own messages are always "read"
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Add optimistic message immediately - UI updates instantly
|
|
253
|
+
setOptimisticMessages((prev) => [...prev, optimisticMsg]);
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const request: SendMessageRequest & { from?: string } = {
|
|
257
|
+
to,
|
|
258
|
+
message: content,
|
|
259
|
+
thread,
|
|
260
|
+
attachments: attachmentIds,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Include sender name for cloud mode (GitHub username)
|
|
264
|
+
if (senderName) {
|
|
265
|
+
request.from = senderName;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Use api.sendMessage which handles:
|
|
269
|
+
// - Workspace proxy routing (in cloud mode)
|
|
270
|
+
// - CSRF token headers
|
|
271
|
+
// - Credentials
|
|
272
|
+
const result = await api.sendMessage(request);
|
|
273
|
+
|
|
274
|
+
if (result.success) {
|
|
275
|
+
// Success! The optimistic message will be cleaned up when
|
|
276
|
+
// the real message arrives via WebSocket
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Failed - remove the optimistic message and show error
|
|
281
|
+
setOptimisticMessages((prev) => prev.filter((m) => m.id !== optimisticId));
|
|
282
|
+
setSendError(result.error || 'Failed to send message');
|
|
283
|
+
return false;
|
|
284
|
+
} catch (_error) {
|
|
285
|
+
// Network error - remove optimistic message
|
|
286
|
+
setOptimisticMessages((prev) => prev.filter((m) => m.id !== optimisticId));
|
|
287
|
+
setSendError('Network error');
|
|
288
|
+
return false;
|
|
289
|
+
} finally {
|
|
290
|
+
setIsSending(false);
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
[effectiveSenderName, senderName]
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
messages: filteredMessages,
|
|
298
|
+
threadMessages,
|
|
299
|
+
currentChannel,
|
|
300
|
+
setCurrentChannel,
|
|
301
|
+
currentThread: currentThreadInternal,
|
|
302
|
+
setCurrentThread,
|
|
303
|
+
activeThreads,
|
|
304
|
+
totalUnreadThreadCount,
|
|
305
|
+
sendMessage,
|
|
306
|
+
isSending,
|
|
307
|
+
sendError,
|
|
308
|
+
...stats,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useOrchestrator hook
|
|
3
|
+
*
|
|
4
|
+
* Covers: spawnAgent cwd parameter forwarding.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// @vitest-environment jsdom
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { renderHook, act } from '@testing-library/react';
|
|
10
|
+
import { useOrchestrator } from './useOrchestrator';
|
|
11
|
+
|
|
12
|
+
// Mock WebSocket to prevent actual connections
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
'WebSocket',
|
|
15
|
+
class MockWebSocket {
|
|
16
|
+
static CLOSED = 3;
|
|
17
|
+
readyState = 3;
|
|
18
|
+
onopen: (() => void) | null = null;
|
|
19
|
+
onclose: (() => void) | null = null;
|
|
20
|
+
onmessage: ((e: unknown) => void) | null = null;
|
|
21
|
+
onerror: ((e: unknown) => void) | null = null;
|
|
22
|
+
close() {
|
|
23
|
+
/* no-op */
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Mock fetch
|
|
29
|
+
const mockFetch = vi.fn();
|
|
30
|
+
|
|
31
|
+
describe('useOrchestrator', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
34
|
+
mockFetch.mockReset();
|
|
35
|
+
// Default mock for workspace/agents fetches
|
|
36
|
+
mockFetch.mockImplementation(async (url: string) => {
|
|
37
|
+
if (url.includes('/workspaces') && !url.includes('/agents')) {
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
json: async () => ({
|
|
41
|
+
workspaces: [
|
|
42
|
+
{ id: 'ws-1', name: 'test', lastActiveAt: new Date().toISOString() },
|
|
43
|
+
],
|
|
44
|
+
activeWorkspaceId: 'ws-1',
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (url.includes('/agents') && !url.includes('POST')) {
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
json: async () => ({ agents: [] }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, json: async () => ({}) };
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.unstubAllGlobals();
|
|
60
|
+
// Re-stub WebSocket for next test
|
|
61
|
+
vi.stubGlobal(
|
|
62
|
+
'WebSocket',
|
|
63
|
+
class MockWebSocket {
|
|
64
|
+
static CLOSED = 3;
|
|
65
|
+
readyState = 3;
|
|
66
|
+
onopen: (() => void) | null = null;
|
|
67
|
+
onclose: (() => void) | null = null;
|
|
68
|
+
onmessage: ((e: unknown) => void) | null = null;
|
|
69
|
+
onerror: ((e: unknown) => void) | null = null;
|
|
70
|
+
close() {
|
|
71
|
+
/* no-op */
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('spawnAgent', () => {
|
|
78
|
+
it('includes cwd in POST body when provided', async () => {
|
|
79
|
+
const { result } = renderHook(() =>
|
|
80
|
+
useOrchestrator({ apiUrl: 'http://localhost:3456', enabled: true }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Wait for initial data fetch
|
|
84
|
+
await vi.waitFor(() => {
|
|
85
|
+
expect(result.current.isLoading).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Set up mock for the spawn POST
|
|
89
|
+
mockFetch.mockImplementationOnce(async (_url: string, opts?: RequestInit) => {
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
json: async () => ({
|
|
93
|
+
id: 'agent-1',
|
|
94
|
+
name: 'worker',
|
|
95
|
+
workspaceId: 'ws-1',
|
|
96
|
+
provider: 'claude',
|
|
97
|
+
status: 'running',
|
|
98
|
+
spawnedAt: new Date().toISOString(),
|
|
99
|
+
restartCount: 0,
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Call spawnAgent with cwd
|
|
105
|
+
await act(async () => {
|
|
106
|
+
await result.current.spawnAgent('worker', 'do stuff', 'claude', 'trajectories');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Find the POST call to /agents (spawn call)
|
|
110
|
+
const spawnCall = mockFetch.mock.calls.find(
|
|
111
|
+
(call) =>
|
|
112
|
+
typeof call[0] === 'string' &&
|
|
113
|
+
call[0].includes('/agents') &&
|
|
114
|
+
call[1]?.method === 'POST',
|
|
115
|
+
);
|
|
116
|
+
expect(spawnCall).toBeTruthy();
|
|
117
|
+
|
|
118
|
+
const body = JSON.parse(spawnCall![1].body as string);
|
|
119
|
+
expect(body.cwd).toBe('trajectories');
|
|
120
|
+
expect(body.name).toBe('worker');
|
|
121
|
+
expect(body.task).toBe('do stuff');
|
|
122
|
+
expect(body.provider).toBe('claude');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('sends cwd as undefined when not provided', async () => {
|
|
126
|
+
const { result } = renderHook(() =>
|
|
127
|
+
useOrchestrator({ apiUrl: 'http://localhost:3456', enabled: true }),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await vi.waitFor(() => {
|
|
131
|
+
expect(result.current.isLoading).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
mockFetch.mockImplementationOnce(async () => ({
|
|
135
|
+
ok: true,
|
|
136
|
+
json: async () => ({
|
|
137
|
+
id: 'agent-1',
|
|
138
|
+
name: 'worker',
|
|
139
|
+
workspaceId: 'ws-1',
|
|
140
|
+
provider: 'claude',
|
|
141
|
+
status: 'running',
|
|
142
|
+
spawnedAt: new Date().toISOString(),
|
|
143
|
+
restartCount: 0,
|
|
144
|
+
}),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
await result.current.spawnAgent('worker', 'do stuff', 'claude');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const spawnCall = mockFetch.mock.calls.find(
|
|
152
|
+
(call) =>
|
|
153
|
+
typeof call[0] === 'string' &&
|
|
154
|
+
call[0].includes('/agents') &&
|
|
155
|
+
call[1]?.method === 'POST',
|
|
156
|
+
);
|
|
157
|
+
expect(spawnCall).toBeTruthy();
|
|
158
|
+
|
|
159
|
+
const body = JSON.parse(spawnCall![1].body as string);
|
|
160
|
+
expect(body.name).toBe('worker');
|
|
161
|
+
// cwd should not be set (undefined serializes away in JSON.stringify)
|
|
162
|
+
expect(body.cwd).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|