Push-Based Live Updates
Overview
MILTON uses a push-based SignalR architecture to deliver real-time document updates to connected Blazor clients. During document generation, the backend pushes full data payloads (DTOs) through SignalR events, enabling the frontend to patch its local state without making any HTTP calls.
Before vs. After
| Aspect | Before (Polling Pattern) | After (Push Pattern) |
|---|---|---|
| Event payload | Metadata only (blockId, status) | Full DTO (ContentBlockDto, SectionDto) |
| Client reaction | HTTP-fetch the updated data from API | Patch local state directly from event payload |
| Structural changes | Full document tree reload via HTTP | Targeted insert/remove of sections and blocks |
| Network overhead | N+1 HTTP requests per batch | Zero HTTP requests during live processing |
| Perceived latency | Noticeable delay per block (round-trip) | Instant UI update on event arrival |
Events
All events are defined as records in NotificationEvents.cs and flow through the DocumentHub SignalR hub. Clients subscribe to events on a per-document group basis.
BlockUpdated
Pushes the full block data when a block’s content or status changes during processing.
| Property | Type | Description |
|---|---|---|
ProjectId | int | Project owning the document |
DocumentId | Guid | Document containing the block |
BlockId | Guid | The updated block |
Status | string | New status (e.g., "Generated", "Error") |
BlockData | ContentBlockDto? | Full block DTO with generated content (null for legacy status-only updates) |
Timestamp | DateTime | UTC timestamp of the event |
Event name: "BlockUpdated"
Source file: NotificationEvents.cs
BlocksAdded
Pushes new blocks when a handler creates sibling blocks (e.g., requirement generation or test case creation).
| Property | Type | Description |
|---|---|---|
ProjectId | int | Project owning the document |
DocumentId | Guid | Document containing the section |
SectionId | Guid | Section to insert new blocks into |
Blocks | List<ContentBlockDto> | The new block DTOs |
Timestamp | DateTime | UTC timestamp of the event |
Event name: "BlocksAdded"
Source file: NotificationEvents.cs
SectionsAdded
Pushes new child sections when a scanner block explodes into sub-sections. Includes IDs of consumed blocks that should be removed from the tree.
| Property | Type | Description |
|---|---|---|
ProjectId | int | Project owning the document |
DocumentId | Guid | Document being modified |
ParentSectionId | Guid | Section that receives the new children |
Sections | List<SectionDto> | New section DTOs (with their blocks) |
RemovedBlockIds | List<Guid> | Block IDs consumed during the transformation |
Timestamp | DateTime | UTC timestamp of the event |
Event name: "SectionsAdded"
Source file: NotificationEvents.cs
BlockRemoved
Notifies when a block is removed from the document (e.g., a consumed scanner block).
| Property | Type | Description |
|---|---|---|
ProjectId | int | Project owning the document |
DocumentId | Guid | Document containing the block |
BlockId | Guid | The removed block |
Timestamp | DateTime | UTC timestamp of the event |
Event name: "BlockRemoved"
Source file: NotificationEvents.cs
Message Flow
The push-based notification chain spans three processes connected by RabbitMQ and SignalR:
flowchart TD subgraph DG["DocumentGenerator (Worker Service)"] RGH["RequirementGeneratorHandler<br/>NotifyBlockUpdatedAsync + NotifyBlocksAddedAsync"] TCGH["TestCaseGeneratorHandler<br/>NotifyBlocksAddedAsync"] RSH["RepoScannerHandler<br/>NotifySectionsAddedAsync"] end RMQ["RabbitMQ<br/>'notifications' queue"] subgraph NS["NotificationService (NotificationHandlers.cs)"] NH["Wolverine Handlers"] end subgraph Hub["SignalR DocumentHub groups"] DocGroup["doc-{documentId}"] ProjGroup["project-{projectId}"] end subgraph Client["Blazor Client (DocumentEditor.razor)"] HBU["HandleBlockUpdated → patches block in local tree"] HBA["HandleBlocksAdded → inserts blocks into correct section"] HSA["HandleSectionsAdded → adds child sections, removes consumed blocks"] HBR["HandleBlockRemoved → removes block from tree"] end DG --> RMQ RMQ --> NS NS --> Hub Hub --> Client
Step-by-Step
-
Handler produces data — A component handler in
MILTON.DocumentGenerator(e.g.,RequirementGeneratorHandler.cs) finishes processing a block and calls methods onIUserNotifier. -
Message published to RabbitMQ — The
WolverineUserNotifier(the DocumentGenerator’s implementation ofIUserNotifier) serializes the data into a Wolverine message (e.g.,NotifyBlocksAddedMessage) and publishes it to the"notifications"RabbitMQ queue. -
NotificationService handles message —
NotificationHandlers.csinMILTON.NotificationServiceconsumes the message from the"notifications"queue and broadcasts it to connected clients viaIHubContext<DocumentHub>. -
SignalR pushes to clients — The handler constructs the event record (e.g.,
BlocksAddedEvent) and sends it to the appropriate SignalR groups (both document-level and project-level). -
Client patches state —
DocumentStateService.csrelays the event toDocumentEditor.razor, which patches its local document tree without any HTTP calls.
Shared Contracts
Event Records
Defined in NotificationEvents.cs under MILTON.Shared/DTOs/Notifications/:
public record BlockUpdatedEvent(int ProjectId, Guid DocumentId, Guid BlockId, string Status, ContentBlockDto? BlockData, DateTime Timestamp);
public record BlocksAddedEvent(int ProjectId, Guid DocumentId, Guid SectionId, List<ContentBlockDto> Blocks, DateTime Timestamp);
public record SectionsAddedEvent(int ProjectId, Guid DocumentId, Guid ParentSectionId, List<SectionDto> Sections, List<Guid> RemovedBlockIds, DateTime Timestamp);
public record BlockRemovedEvent(int ProjectId, Guid DocumentId, Guid BlockId, DateTime Timestamp);Wolverine Messages
Defined in ClientNotificationMessages.cs under MILTON.Shared/Messaging/Notifications/:
public record NotifyBlockUpdatedMessage(int ProjectId, Guid DocumentId, Guid BlockId, string Status, ContentBlockDto? BlockData = null);
public record NotifyBlocksAddedMessage(int ProjectId, Guid DocumentId, Guid SectionId, List<ContentBlockDto> Blocks);
public record NotifySectionsAddedMessage(int ProjectId, Guid DocumentId, Guid ParentSectionId, List<SectionDto> Sections, List<Guid> RemovedBlockIds);
public record NotifyBlockRemovedMessage(int ProjectId, Guid DocumentId, Guid BlockId);IUserNotifier Interface
Defined in IUserNotifier.cs under MILTON.BackendCore/Application/Interfaces/:
Task NotifyBlockUpdatedAsync(int projectId, Guid documentId, Guid blockId, string status, ContentBlockDto? blockData = null, CancellationToken ct = default);
Task NotifyBlocksAddedAsync(int projectId, Guid documentId, Guid sectionId, List<ContentBlockDto> blocks, CancellationToken ct = default);
Task NotifySectionsAddedAsync(int projectId, Guid documentId, Guid parentSectionId, List<SectionDto> sections, List<Guid> removedBlockIds, CancellationToken ct = default);
Task NotifyBlockRemovedAsync(int projectId, Guid documentId, Guid blockId, CancellationToken ct = default);Frontend Handling
DocumentStateService
DocumentStateService.cs manages the SignalR connection and exposes typed C# events for each notification:
public event Func<BlockUpdatedEvent, Task>? OnBlockUpdated;
public event Func<BlocksAddedEvent, Task>? OnBlocksAdded;
public event Func<SectionsAddedEvent, Task>? OnSectionsAdded;
public event Func<BlockRemovedEvent, Task>? OnBlockRemoved;The service registers SignalR handlers that forward events to subscribers:
_connection.On<BlocksAddedEvent>("BlocksAdded", async evt =>
{
if (OnBlocksAdded is not null)
await OnBlocksAdded.Invoke(evt);
});DocumentEditor.razor
The document editor subscribes to all events on initialization and unsubscribes on disposal:
| Handler | Behavior |
|---|---|
HandleBlockUpdated | Finds the block in the local tree and replaces it with the pushed ContentBlockDto. Falls back to status-only update if BlockData is null. |
HandleBlocksAdded | Locates the target section by SectionId, then appends each new block. Supports parent-child nesting via ParentBlockId. |
HandleSectionsAdded | Removes consumed blocks (via RemovedBlockIds), then inserts new child sections under the parent. Falls back to root-level insert if parent not found. |
HandleBlockRemoved | Recursively searches the document tree and removes the block by ID. |
All handlers call InvokeAsync(StateHasChanged) and _sectionDropContainer?.Refresh() to trigger Blazor re-rendering.
Note: Duplicate detection is built in — each handler checks for existing IDs before inserting to prevent duplicates from message redelivery.
CSS Animations
New blocks slide in with a smooth animation defined in BlockRenderer.razor.css:
.block-renderer {
animation: blockSlideIn 0.4s ease-out;
}
@keyframes blockSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}The animation re-triggers on content changes because BlockRenderer.razor uses a composite @key directive:
<div class="block-renderer" @key="@($"{Block.Id}-{Block.Status}-{Block.Body?.GetHashCode()}")">When a block’s status or body changes, Blazor treats it as a new element (different key), causing the slide-in animation to replay.
Adding a New Push Event
To add a new push event to the system, follow these steps:
1. Define the Event Record
Add a new record to MILTON.Shared/DTOs/Notifications/NotificationEvents.cs:
public record MyNewEvent(int ProjectId, Guid DocumentId, /* your fields */, DateTime Timestamp);2. Define the Wolverine Message
Add a corresponding message to MILTON.Shared/Messaging/Notifications/ClientNotificationMessages.cs:
public record NotifyMyNewMessage(int ProjectId, Guid DocumentId, /* your fields */);3. Add the Interface Method
Add a method to MILTON.BackendCore/Application/Interfaces/IUserNotifier.cs:
Task NotifyMyNewAsync(int projectId, Guid documentId, /* params */, CancellationToken ct = default);4. Implement in WolverineUserNotifier
In MILTON.DocumentGenerator/WolverineUserNotifier.cs, publish the message:
public async Task NotifyMyNewAsync(int projectId, Guid documentId, /* params */, CancellationToken ct = default)
{
await bus.PublishAsync(new NotifyMyNewMessage(projectId, documentId, /* args */));
}5. Route the Message
In MILTON.DocumentGenerator/Program.cs, add the message type to the "notifications" queue routing.
6. Handle on the API Server
Add a handler in MILTON/Infrastructure/Notifications/NotificationHandlers.cs:
public Task Handle(NotifyMyNewMessage message, IUserNotifier notifier)
{
return notifier.NotifyMyNewAsync(message.ProjectId, message.DocumentId, /* args */);
}7. Implement in SignalRUserNotifier
In MILTON/Infrastructure/Notifications/SignalRUserNotifier.cs, send via SignalR:
public async Task NotifyMyNewAsync(int projectId, Guid documentId, /* params */, CancellationToken ct = default)
{
var evt = new MyNewEvent(projectId, documentId, /* args */, DateTime.UtcNow);
await _hub.Clients.Group(DocumentHub.GroupName(documentId)).SendAsync("MyNew", evt, ct);
await _hub.Clients.Group(DocumentHub.ProjectGroupName(projectId)).SendAsync("MyNew", evt, ct);
}8. Subscribe on the Client
In MILTON.Client/Services/DocumentStateService.cs, register the handler and expose an event:
public event Func<MyNewEvent, Task>? OnMyNew;
// In connection setup:
_connection.On<MyNewEvent>("MyNew", async evt =>
{
if (OnMyNew is not null)
await OnMyNew.Invoke(evt);
});9. Handle in the UI
In DocumentEditor.razor, subscribe to the event and patch local state accordingly.