Client application (MILTON.Client)

Overview

MILTON.Client is the Blazor WebAssembly frontend for MILTON. It hosts the routed user experience for project management, project configuration, document authoring, and template editing. The application shell lives in MainLayout.razor and ProjectLayout.razor, routed pages live in the Pages/ folder, reusable components live in the Components/ folder, real-time document updates are handled by DocumentStateService.cs, and client-side L1 caching is implemented through the scoped store classes in the StateContainer/ folder.

This client surface supports the backend feature areas documented in Projects Feature, Documents Feature, and Project Configuration Feature.

Folder Structure

graph LR
    Root["MILTON.Client/"]

    Root --> Pages["Pages/"]
    Pages --> Home["Home.razor — /"]
    Pages --> PD["ProjectDashboard.razor"]
    Pages --> DL["DocumentsList.razor"]
    Pages --> DE["DocumentEditor.razor"]
    Pages --> TL["Templates.razor"]
    Pages --> CT["CreateTemplate.razor"]
    Pages --> ET["EditTemplate.razor"]
    Pages --> PC["ProjectConfig.razor"]
    Pages --> NP["New-project.razor"]
    Pages --> NF["NotFound.razor"]

    Root --> Components["Components/"]
    Components --> Comp["Component.razor"]
    Components --> ITD["InstantiateTemplateDialog.razor"]
    Components --> Docs["Documents/"]
    Docs --> BR["BlockRenderer.razor"]
    Docs --> DBV["DefaultBlockView.razor"]
    Docs --> DPP["DocumentPreviewPanel.razor"]
    Docs --> RS["RenderSection.razor"]
    Docs --> RFA["RepoFileAutocomplete.razor"]
    Docs --> RC["RequirementCard.razor"]
    Docs --> SC["ScannerCard.razor"]
    Docs --> SSD["SectionSettingsDrawer.razor"]
    Docs --> STI["SectionTreeItem.razor"]
    Docs --> TPP["TemplatePreviewPanel.razor"]
    Docs --> TSE["TemplateSectionEditor.razor"]
    Docs --> TCC["TestCaseCard.razor"]
    Docs --> TV["TraceView.razor"]

    Root --> Layout["Layout/"]
    Layout --> ML["MainLayout.razor"]
    Layout --> PL["ProjectLayout.razor"]
    Layout --> RM["ReconnectModal.razor"]
    Layout --> RTL["RedirectToLogin.razor"]

    Root --> Services["Services/"]
    Services --> DSS["DocumentStateService.cs"]

    Root --> State["StateContainer/"]
    State --> PS["ProjectStore.cs"]
    State --> DS["DocumentStore.cs"]
    State --> TS["TemplateStore.cs"]

For detailed component-level documentation, see Components.md, Pages.md, and Infrastructure.md.

Architecture

flowchart TD
    subgraph Client ["MILTON.Client — Blazor WASM"]
        Pages["Pages/<br/>Home · ProjectDashboard · DocumentsList<br/>Templates · DocumentEditor · ProjectConfig"]
        Components["Components/<br/>BlockRenderer · RequirementCard · ScannerCard<br/>TestCaseCard · RenderSection · InstantiateTemplateDialog"]
        Stores["StateContainer/<br/>ProjectStore · DocumentStore · TemplateStore"]
        DSS["DocumentStateService"]
    end

    API["MILTON API<br/>/api/projects · /api/documents · /api/projects/{id}/..."]
    Hub["DocumentHub<br/>/hubs/document"]
    DI["Blazor WASM DI scope<br/>shared across route changes"]

    Pages -- "HTTP + JSON" --> API
    API -- "responses" --> Pages
    Pages -- "reads & writes" --> Components
    Components -- "bound to data from" --> Stores
    Stores -- "scoped lifetime" --> DI
    DSS <-- "SignalR" --> Hub

Technology

  • Framework: Blazor WebAssembly on ASP.NET Core 10
  • UI Library: MudBlazor 8.15.0
  • Real-time Engine: SignalR via DocumentStateService.cs
  • Client state model: Scoped dependency-injected in-memory stores registered in Program.cs

L1 In-Memory Cache

The client’s L1 cache is implemented with scoped services registered in Program.cs:

builder.Services.AddScoped<ProjectStore>();
builder.Services.AddScoped<DocumentStore>();
builder.Services.AddScoped<TemplateStore>();

Because these stores are registered with AddScoped, each running browser client gets its own in-memory cache instance. The cache survives navigation between routed pages inside the active app session, but it is reset on a full page reload or in a new browser tab.

Store Catalog

StoreImplementation fileCached dataKeying strategyPrimary consumers
ProjectStoreStateContainer/ProjectStore.csList<GetProjectResponse>Single list for the active client scopeHome.razor, ProjectLayout.razor, ProjectDashboard.razor, ProjectConfig.razor
DocumentStoreStateContainer/DocumentStore.csDictionary<int, List<DocumentSummaryResponse>> and Dictionary<Guid, DocumentResponse>Project ID for summary lists, document ID for editor detail payloadsProjectDashboard.razor, DocumentsList.razor, CreateTemplate.razor, EditTemplate.razor, DocumentEditor.razor, InstantiateTemplateDialog.razor
TemplateStoreStateContainer/TemplateStore.csDictionary<int, List<TemplateResponse>>Project IDProjectDashboard.razor, Templates.razor, EditTemplate.razor

Cache Population

Project cache

ProjectStore is populated from GET /api/projects the first time a page needs the project list.

  • Home.razor loads the projects list if ProjectStore.HasData is false, then renders directly from ProjectStore.Projects.
  • ProjectLayout.razor reuses the same cached list to resolve the active project title for project-scoped navigation.
  • ProjectDashboard.razor first tries to resolve the current project from cache before falling back to GET /api/projects.
  • ProjectConfig.razor always fetches the project configuration payload from GET /api/projects/{projectId}/config, but only calls GET /api/projects when ProjectStore is empty.

Document cache

DocumentStore holds both project document summaries and document detail payloads.

  • ProjectDashboard.razor, DocumentsList.razor, CreateTemplate.razor, and EditTemplate.razor populate ProjectDocuments[projectId] from GET /api/projects/{projectId}/documents only when the project entry is missing.
  • DocumentEditor.razor stores the result of GET /api/documents/{id} in DocumentDetails[documentId] the first time a document editor is opened.
  • InstantiateTemplateDialog.razor clears the project’s document-summary cache after a successful template instantiation so the next list read will fetch fresh document summaries.

Template cache

TemplateStore holds template lists per project.

  • ProjectDashboard.razor and Templates.razor populate ProjectTemplates[projectId] from GET /api/projects/{projectId}/templates on the first read for that project.
  • EditTemplate.razor first searches ProjectTemplates[projectId] for the target template. If the template is not already present, it fetches GET /api/documents/templates/{templateId} and appends the result into the cached project list.

Invalidation Rules

The current implementation uses explicit invalidation after successful writes instead of background reconciliation.

Mutation pathCache actionReason
New-project.razor after POST /api/projectsProjectStore.Clear()Forces the next project-list read to include the newly created project
CreateTemplate.razor after POST /api/documents/templatesTemplateStore.ClearForProject(ProjectId)Forces the next template-list read to include the new template
EditTemplate.razor after PUT /api/documents/templates/{templateId}TemplateStore.ClearForProject(ProjectId)Prevents stale template payloads from being reused
InstantiateTemplateDialog.razor after POST /api/documents/templates/instantiateDocumentStore.ClearForProject(ProjectId)Forces project document summaries to be refetched after document creation
Full page reload or new browser tabNew scoped store instancesResets all in-memory state

Note: DocumentStore.ClearForProject(projectId) only removes project document-summary lists. The DocumentDetails cache in DocumentStore has no dedicated invalidation method in the current implementation, so detail entries remain in memory for the lifetime of the scoped client instance.

Security and Offline Constraints

Due to strict requirements, all scripts, styles, and web fonts are vendored locally. The frontend must not depend on external CDNs or external runtime APIs because it is designed to support offline and air-gapped environments.

Real-time Sync (SignalR)

Live updates for document processing flow through DocumentStateService, which maintains a SignalR connection to the backend DocumentHub. The hub organizes messaging into two types of groups to scale notifications efficiently:

  1. Document Groups (JoinDocumentGroup(Guid documentId)): Pushes granular progress logs, completion, and error events for a specific document. Subscribed to by detail pages like DocumentEditor.razor via JoinDocumentAsync.
  2. Project Groups (JoinProjectGroup(int projectId)): Pushes document summarization updates and completion events across an entire project. Subscribed to by list pages like ProjectDashboard.razor via JoinProjectAsync.

Preventing Memory Leaks

Because DocumentStateService lives as a scoped service beyond the lifecycle of individual components, all components subscribing to state events must implement IDisposable or IAsyncDisposable to unregister their event handlers. Failing to do so creates memory leaks where destroyed components continue to receive events.

Example usage:

// Subscribe in initialization
protected override async Task OnInitializedAsync()
{
    await StateService.StartAsync();
    StateService.OnProgress += HandleProgress;
    await StateService.JoinDocumentAsync(documentId);
}
 
// Unsubscribe and leave group to prevent leaks and unnecessary traffic
public async ValueTask DisposeAsync()
{
    StateService.OnProgress -= HandleProgress;
    await StateService.LeaveDocumentAsync(documentId);
}

3 items under this folder.