ChatDock Events & Subscriptions: Three Patterns for Agent-to-UI Communication
ChatDock handles chat rendering, thinking indicators, and agent lifecycle internally. But most real-world Blazor apps need more — the parent component needs to react when messages arrive: refresh a data table, update a dashboard counter, toggle UI state. FabrCore provides three patterns for this, each designed for a different scope.
Pattern 1: OnMessageReceived Callback (Recommended)
The simplest approach. ChatDock exposes callback parameters that the parent component can bind directly. OnMessageReceived controls whether each message appears in chat — return true to display, false to suppress:
| Parameter | Type | When It Fires |
|---|---|---|
OnMessageReceived | Func<AgentMessage, Task<bool>>? | Agent sends a message (thinking messages excluded). Return true to display, false to suppress. |
OnMessageSent | EventCallback<string> | User message sent successfully |
When no callback is set, all non-thinking messages display automatically (backwards compatible). No manual subscribe/unsubscribe or Dispose boilerplate needed.
<ChatDock UserHandle="@_userHandle"
AgentHandle="document-agent"
AgentType="DocumentAgent"
OnMessageReceived="OnAgentResponse"
OnMessageSent="OnUserMessage" />
@code {
private Task<bool> OnAgentResponse(AgentMessage message)
{
// Full AgentMessage — access .Message, .State, .Args, .MessageType
_activityLog.Insert(0, $"Agent: {message.Message}");
return Task.FromResult(true); // show in chat
}
private void OnUserMessage(string text)
{
_activityLog.Insert(0, $"You: {text}");
}
}
Filtering messages: Agents often send structured messages (status updates, state changes) that the parent needs to process but should not appear as chat bubbles. Return false to suppress:
@code {
private Task<bool> HandleMessage(AgentMessage message)
{
if (message.MessageType == "status")
{
ProcessStatusUpdate(message);
return Task.FromResult(false); // suppress from chat
}
return Task.FromResult(true); // show in chat
}
}
When to use: Any time a parent component needs to react to or filter messages from a specific ChatDock. This covers 90% of use cases — dashboard updates, activity logs, triggering data refreshes, suppressing non-chat messages.
OnMessageReceived provides the full AgentMessage object. Use message.State to pass structured metadata from the agent (e.g., State["action"] = "file_created") that the parent can use to decide what to refresh or whether to display.
You can also coordinate multiple ChatDocks by using lambda expressions to identify which agent responded:
<ChatDock AgentHandle="research-agent"
OnMessageReceived="msg => OnResponse(\"research\", msg)" />
<ChatDock AgentHandle="writer-agent"
OnMessageReceived="msg => OnResponse(\"writer\", msg)" />
Pattern 2: IClientContext.AgentMessageReceived
If you need to subscribe to all agent messages for a user — not just from one ChatDock — inject IClientContextFactory and subscribe to the AgentMessageReceived event directly. This gives broader visibility than a single ChatDock provides.
@inject IClientContextFactory ClientContextFactory
@implements IAsyncDisposable
@code {
private IClientContext? _ctx;
protected override async Task OnInitializedAsync()
{
_ctx = await ClientContextFactory
.GetOrCreateAsync(_userHandle);
_ctx.AgentMessageReceived += HandleAllMessages;
}
private void HandleAllMessages(
object? sender, AgentMessage msg)
{
InvokeAsync(() => { _count++; StateHasChanged(); });
}
public async ValueTask DisposeAsync()
{
// Unsubscribe — do NOT dispose the shared context
if (_ctx != null)
_ctx.AgentMessageReceived -= HandleAllMessages;
}
}
When to use: Dashboard widgets, global notification systems, audit logging — anywhere you need to see messages from all agents for a user, not just one ChatDock's agent.
AgentMessageReceived fires on Orleans thread pool threads. Always wrap UI updates in InvokeAsync(() => StateHasChanged()). Also: when using GetOrCreateAsync, the context is shared — do not dispose it. Only unsubscribe your handler.
Pattern 3: ChatDockManager.StateChanged
The ChatDockManager fires StateChanged whenever any dock opens or closes. Use it to update surrounding layout, show overlays, or adjust navigation state.
@implements IDisposable
@code {
private ChatDockManager _mgr = new();
protected override void OnInitialized()
{
_mgr.StateChanged += OnDockStateChanged;
}
private void OnDockStateChanged()
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
_mgr.StateChanged -= OnDockStateChanged;
}
}
Check _mgr.ExpandedDockId to know which dock is open (or null if all are collapsed).
When to use: Layout adjustments, overlay dimming, sidebar collapse, or any UI that needs to react to dock expand/collapse — not message content.
Which Pattern Should I Use?
| Scenario | Pattern |
|---|---|
| React to messages from a specific ChatDock | Pattern 1 — EventCallbacks |
| Listen to all agent messages for a user | Pattern 2 — IClientContext event |
| React to dock open/close state | Pattern 3 — ChatDockManager.StateChanged |
| Message handling + UI coordination | Patterns 1 + 3 together |
Notes
- Thread safety: Pattern 2 fires on Orleans threads — always use
InvokeAsync. Pattern 1 callbacks do not requireInvokeAsync. - Message filtering:
OnMessageReceived(Pattern 1) is aFunc<AgentMessage, Task<bool>>?. Returntrueto display,falseto suppress. When null, all non-thinking messages display. - Disposal: Pattern 1 requires no cleanup. Patterns 2 and 3 require explicit unsubscribe (
-=) inDispose/DisposeAsync. - Thinking messages:
OnMessageReceived(Pattern 1) excludes thinking messages.AgentMessageReceived(Pattern 2) delivers all messages including thinking — filter bymessage.MessageTypeif needed.
Built with FabrCore on .NET 10.
Builder of FabrCore and OpenCaddis.