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

AspectBefore (Polling Pattern)After (Push Pattern)
Event payloadMetadata only (blockId, status)Full DTO (ContentBlockDto, SectionDto)
Client reactionHTTP-fetch the updated data from APIPatch local state directly from event payload
Structural changesFull document tree reload via HTTPTargeted insert/remove of sections and blocks
Network overheadN+1 HTTP requests per batchZero HTTP requests during live processing
Perceived latencyNoticeable 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.

PropertyTypeDescription
ProjectIdintProject owning the document
DocumentIdGuidDocument containing the block
BlockIdGuidThe updated block
StatusstringNew status (e.g., "Generated", "Error")
BlockDataContentBlockDto?Full block DTO with generated content (null for legacy status-only updates)
TimestampDateTimeUTC 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).

PropertyTypeDescription
ProjectIdintProject owning the document
DocumentIdGuidDocument containing the section
SectionIdGuidSection to insert new blocks into
BlocksList<ContentBlockDto>The new block DTOs
TimestampDateTimeUTC 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.

PropertyTypeDescription
ProjectIdintProject owning the document
DocumentIdGuidDocument being modified
ParentSectionIdGuidSection that receives the new children
SectionsList<SectionDto>New section DTOs (with their blocks)
RemovedBlockIdsList<Guid>Block IDs consumed during the transformation
TimestampDateTimeUTC 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).

PropertyTypeDescription
ProjectIdintProject owning the document
DocumentIdGuidDocument containing the block
BlockIdGuidThe removed block
TimestampDateTimeUTC 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

  1. Handler produces data — A component handler in MILTON.DocumentGenerator (e.g., RequirementGeneratorHandler.cs) finishes processing a block and calls methods on IUserNotifier.

  2. Message published to RabbitMQ — The WolverineUserNotifier (the DocumentGenerator’s implementation of IUserNotifier) serializes the data into a Wolverine message (e.g., NotifyBlocksAddedMessage) and publishes it to the "notifications" RabbitMQ queue.

  3. NotificationService handles messageNotificationHandlers.cs in MILTON.NotificationService consumes the message from the "notifications" queue and broadcasts it to connected clients via IHubContext<DocumentHub>.

  4. 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).

  5. Client patches stateDocumentStateService.cs relays the event to DocumentEditor.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:

HandlerBehavior
HandleBlockUpdatedFinds the block in the local tree and replaces it with the pushed ContentBlockDto. Falls back to status-only update if BlockData is null.
HandleBlocksAddedLocates the target section by SectionId, then appends each new block. Supports parent-child nesting via ParentBlockId.
HandleSectionsAddedRemoves consumed blocks (via RemovedBlockIds), then inserts new child sections under the parent. Falls back to root-level insert if parent not found.
HandleBlockRemovedRecursively 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.