Skip to content

fix: stabilize relay token and show all DM conversations#62

Merged
khaliqgant merged 7 commits intomainfrom
fix/agent-dm-visibility-channels
Mar 10, 2026
Merged

fix: stabilize relay token and show all DM conversations#62
khaliqgant merged 7 commits intomainfrom
fix/agent-dm-visibility-channels

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 10, 2026

Summary

  • Fix real-time channel messages: Cache the registered agent token/name in memory so registerOrRotate isn't called on every /api/relay-config request. Previously each call rotated the token, invalidating the frontend's Relaycast WebSocket connection and breaking real-time message delivery.
  • Fix agent-to-agent DM visibility: Replace the agent-scoped useDMs() hook with a new useAllDMs() hook that uses useRelay().allDmConversations() (workspace-level API). This makes agent-to-agent DMs visible in the DM sidebar, not just DMs involving the dashboard agent.
  • Real-time DM updates: The new hook listens for dm.received and group_dm.received WebSocket events to auto-refetch conversations.

Test plan

  • Restart dashboard server, open dashboard, navigate to #general
  • Post a message via Relaycast MCP/API — verify it appears in #general in real-time without page refresh
  • Check browser DevTools for WebSocket connection stability (no repeated disconnects)
  • Check DM sidebar — verify agent-to-agent DMs (e.g., between two non-dashboard agents) appear
  • Verify switching workspaces clears the cached token and re-registers

🤖 Generated with Claude Code

khaliqgant and others added 2 commits March 10, 2026 11:40
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>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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(...);
  }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +425 to +428
const parts = selectedChannelId.split(':').slice(1);
const otherParticipant = parts.find(
p => p.toLowerCase() !== senderName.toLowerCase()
) ?? parts[0];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 getRelayDmParticipantNamenormalizeDashboardName. 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];
Open in Devin Review

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>
@khaliqgant khaliqgant changed the title fix: show agent-to-agent DM conversations in channels view fix: use SDK client for DM reads to fix missing DM conversations Mar 10, 2026
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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
@khaliqgant khaliqgant changed the title fix: use SDK client for DM reads to fix missing DM conversations fix: stabilize relay token and show all DM conversations Mar 10, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 15 additional findings in Devin Review.

Open in Devin Review

// we derive them here at this level too.
const { configured: relayConfigured } = useRelayConfigStatus();
const relayDMsState = useRelayDMs();
const allDMsState = useAllDMs();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +55 to +68
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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]);
Open in Devin Review

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>
@khaliqgant khaliqgant merged commit 0f8ba8a into main Mar 10, 2026
1 check passed
@khaliqgant khaliqgant deleted the fix/agent-dm-visibility-channels branch March 10, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant