fix: stabilize relay token and show all DM conversations#62
Conversation
DM conversations between agents were fetched and normalized correctly but never surfaced in the channels view because: 1. ChannelProvider only created #channel entries, never dm: channels 2. App.tsx explicitly filtered out isDm/dm: channels from sidebar 3. SendProvider had no DM message loading or sending path Changes: - ChannelProvider: synthesize dm: channels from useDMs() conversations - Sidebar: add collapsible "Direct Messages" section with @ icon - App.tsx: pass dmChannels prop to Sidebar - SendProvider: add DM message loading, display, and sending via SDK Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This reverts commit 23f867f.
There was a problem hiding this comment.
🔴 DM messages with attachments or thread replies silently fail to send
When a DM channel is selected (relayDmEligible is true) but the message has attachments or is a thread reply, the first if condition at line 423 is false (because hasAttachments or threadId is truthy). The second else if at line 432 is also false because relayEligible requires the channel to start with #, but DM channels start with dm:. So execution falls through to the generic else block at line 444, which calls sendChannelApiMessage with a channel ID like dm:Alice:Bob. The broker API at packages/dashboard/src/components/channels/api.ts:234 sends this as channel: "dm:Alice:Bob" — a format the server doesn't handle as a valid channel. This will fail, but an optimistic message was already appended at line 413, so the user sees a ghost message that was never delivered.
(Refers to lines 444-449)
Prompt for agents
In packages/dashboard/src/providers/SendProvider.tsx, in the handleSendChannelMessage callback around lines 423-450, the DM-eligible path only handles simple messages (no attachments, no threadId). When a DM message has attachments or a thread reply, the code falls through to the else block which calls sendChannelApiMessage with a dm: prefixed channel ID the broker API doesn't understand.
Fix by either:
1. Extending the relayDmEligible branch to handle attachments and thread replies (e.g., by uploading attachments first then calling relaySendDMState.send with a formatted message), or
2. Showing an explicit error/toast to the user that attachments and thread replies are not yet supported for DMs, and preventing the optimistic message from being appended in that case.
At minimum, prevent the else fallback from being reached for DM channels by adding a guard like:
} else if (relayDmEligible) {
throw new Error('Attachments and thread replies are not yet supported for DM channels');
} else {
await sendChannelApiMessage(...);
}
Was this helpful? React with 👍 or 👎 to provide feedback.
| const parts = selectedChannelId.split(':').slice(1); | ||
| const otherParticipant = parts.find( | ||
| p => p.toLowerCase() !== senderName.toLowerCase() | ||
| ) ?? parts[0]; |
There was a problem hiding this comment.
🔴 DM sender name may not match normalized participant names in channel ID, causing DM to be sent to wrong recipient
In handleSendChannelMessage, the other DM participant is extracted by comparing channel ID segments against senderName (line 426-428). The senderName is derived from currentUser?.displayName (raw display name), but the channel ID segments come from mapRelayDmConversationToDashboard (packages/dashboard/src/providers/ChannelProvider.tsx:58-66) which normalizes participant names through getRelayDmParticipantName → normalizeDashboardName. If the relay identifies the current user differently from their display name (e.g., the relay knows the user as Dashboard-abc123 which normalizes to a workspace name, but currentUser.displayName is "John"), then parts.find(p => p.toLowerCase() !== senderName.toLowerCase()) will fail to exclude the sender's normalized name and instead return the first alphabetical non-matching participant — which could be the sender's own normalized identity rather than the intended recipient.
Prompt for agents
In packages/dashboard/src/providers/SendProvider.tsx lines 425-428, the other participant extraction compares channel ID segments against senderName using case-insensitive string matching. However, the channel ID segments are normalized via getRelayDmParticipantName/normalizeDashboardName, while senderName is the raw currentUser.displayName. These may not match.
To fix: either normalize senderName the same way before comparison (import and use normalizeRelayIdentity or normalizeDashboardName), or store the relay-level identity of the current user alongside the display name and use that for the comparison. For example:
import { getRelayDmParticipantName } from '../lib/relaycastMessageAdapters';
// ... in the DM send path:
const normalizedSender = normalizeDashboardName(senderName).toLowerCase();
const otherParticipant = parts.find(p => p.toLowerCase() !== normalizedSender) ?? parts[0];
Was this helpful? React with 👍 or 👎 to provide feedback.
Replace raw HTTP reader client with SDK-based getCachedClient to ensure DM conversations are fetched with proper agent authentication. Handle both snake_case and camelCase field names since the SDK camelizes API responses. Update tests to mock SDK createRelaycastClient consistently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pass unfiltered messages to useDirectMessage when in DM view so agent-to-agent DM messages aren't pre-filtered out by useMessages. Derive all DM participants (not just those involving the human) so messages like IssueReviewer → fixer-530 appear in the conversation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace agent-level dms.conversations() with workspace-level RelayCast.allDmConversations() and RelayCast.dmMessages(). The agent- level API only returns conversations the dashboard-reader participates in, hiding agent-to-agent DMs like IssueReviewer → fixer-530. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevent repeated registerOrRotate calls from invalidating the frontend's WebSocket token by caching the agent identity in memory. This fixes real-time channel message delivery via the Relaycast SDK. Replace the agent-scoped useDMs hook with a workspace-level useAllDMs hook that calls allDmConversations(), making agent-to-agent DMs visible in the DM sidebar alongside user DMs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // we derive them here at this level too. | ||
| const { configured: relayConfigured } = useRelayConfigStatus(); | ||
| const relayDMsState = useRelayDMs(); | ||
| const allDMsState = useAllDMs(); |
There was a problem hiding this comment.
🟡 Duplicate useAllDMs() hook instances cause doubled API calls and event listeners
useAllDMs() is invoked independently in both MessageProviderInnerWithSend (line 840) and its child component MessageProviderInner (line 379). Since MessageProviderInner renders inside MessageProviderInnerWithSend, both hook instances are active simultaneously. Each instance independently calls relay.allDmConversations() on mount and registers separate WebSocket event listeners for dm.received and group_dm.received. This doubles all DM-related API traffic and event handler registrations.
Component nesting showing the duplication
MessageProviderInnerWithSend (calls useAllDMs() at line 840) renders MessageProviderInner (calls useAllDMs() at line 379) as a child. Both hooks fetch independently.
Prompt for agents
In packages/dashboard/src/providers/MessageProvider.tsx, useAllDMs() is called at both line 379 (inside MessageProviderInner) and line 840 (inside MessageProviderInnerWithSend). Since MessageProviderInner is a child of MessageProviderInnerWithSend, both hooks independently fetch and subscribe. Either: (1) extract allDMsState from context so only one call is needed (e.g., pass it down via a shared provider), or (2) remove the call at line 840 and pass the conversations from the inner component's allDMsState up to MessageProviderInnerWithSend via a callback/context. The simplest fix is to call useAllDMs() only once in MessageProviderInnerWithSend and pass allDMsState.conversations as a prop to MessageProviderInner.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const fetchConversations = useCallback(async () => { | ||
| if (!configured || !relay || fetchingRef.current) return; | ||
| fetchingRef.current = true; | ||
| try { | ||
| const data = await relay.allDmConversations(); | ||
| setConversations(Array.isArray(data) ? data as AllDmConversation[] : []); | ||
| setError(null); | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err : new Error(String(err))); | ||
| } finally { | ||
| setLoading(false); | ||
| fetchingRef.current = false; | ||
| } | ||
| }, [configured, relay]); |
There was a problem hiding this comment.
🔴 useCallback depends on relay local variable causing potential infinite re-fetch loop
In useAllDMs, the relay variable is assigned from useRelay() on every render (line 50) and used as a dependency in useCallback (line 68). If useRelay() returns a new object reference on any render, fetchConversations gets a new identity, which triggers the useEffect (line 77), which calls fetchConversations, which calls setConversations/setError/setLoading, which triggers another render — creating an infinite fetch loop. The fetchingRef guard only prevents concurrent fetches, not sequential re-triggering after each fetch completes (the finally block at line 66 resets fetchingRef.current = false).
Prompt for agents
In packages/dashboard/src/components/hooks/useAllDMs.ts, the relay object from useRelay() is used as a useCallback dependency (line 68), but it may be a new reference each render. Store the relay instance in a ref to stabilize it:
1. Add: const relayRef = useRef(relay);
2. Update the ref each render: relayRef.current = relay;
3. In useCallback, use relayRef.current instead of relay, and remove relay from the dependency array:
const fetchConversations = useCallback(async () => {
if (!configured || !relayRef.current || fetchingRef.current) return;
fetchingRef.current = true;
try {
const data = await relayRef.current.allDmConversations();
setConversations(Array.isArray(data) ? data as AllDmConversation[] : []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setLoading(false);
fetchingRef.current = false;
}
}, [configured]);
Was this helpful? React with 👍 or 👎 to provide feedback.
- Scope DM participant derivation to only messages involving the current human or selected agents, preventing cross-conversation leakage - Include thread_id, reply_count, and reactions in DM message mapping - Cache workspace-level RelayCast client to avoid N+1 allocations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
registerOrRotateisn't called on every/api/relay-configrequest. Previously each call rotated the token, invalidating the frontend's Relaycast WebSocket connection and breaking real-time message delivery.useDMs()hook with a newuseAllDMs()hook that usesuseRelay().allDmConversations()(workspace-level API). This makes agent-to-agent DMs visible in the DM sidebar, not just DMs involving the dashboard agent.dm.receivedandgroup_dm.receivedWebSocket events to auto-refetch conversations.Test plan
🤖 Generated with Claude Code