MODULE 05 · LESSON 5.4
Turn WebSocket deliveries into a usable chat interface
Build a MAUI chat screen around the transport service.
Lesson 24 of 86 · FabrCore 2.0
Overview
The page displays a projection of application state; it does not own network reliability. A view model with an observable transcript, composer and connection status keeps this boundary explicit. A stream of delivery frames is not necessarily a stream of individual model tokens.
The transcript is application state
A CollectionView displays messages already accepted into the view model or local store. It should not be the only place a delivery exists. Pending sends, delivered replies and connection errors are different states with different UI treatments. Dispatch collection changes onto the UI thread instead of updating controls directly from the socket reader.
Do not assume every frame is a token
The delivery stream contains application envelopes. A complete response, event or status update is not necessarily a fragment of model text. Interpret the message contract before appending content. Likewise, disabling Send while disconnected is a UI decision that should match the service's queue/retry behavior rather than hide a failed network operation.
Bind deliveries to the chat page
Download the Operations Desk source. The README lists project setup, package prerequisites and local ports.
- Bind ChatPage.xaml to a view model with transcript items, selected agent, input text and an asynchronous send command. Dispatch collection/property changes through MainThread.
- Render _thinking and _status as activity, _error as an error state, and ordinary replies as transcript items. Correlate responses so accepted requests do not become duplicate final replies.
- Keep sign-in and protected token storage in the client authentication service. For cards, use a verified native renderer or a text/action fallback; Blazor Surface components cannot render directly in MAUI XAML.
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="OperationsDesk.ChatClient.ChatPage" Title="Operations Desk">
<Grid Padding="20" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
<Picker ItemsSource="{Binding Agents}" SelectedItem="{Binding SelectedAgent}"
Title="Choose an agent" />
<Label Grid.Row="1" Text="{Binding Status}" SemanticProperties.Description="Connection status" />
<CollectionView Grid.Row="2" ItemsSource="{Binding Messages}">
<CollectionView.ItemTemplate>
<DataTemplate><Label Padding="8" Text="{Binding Text}" /></DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" ColumnSpacing="12">
<Entry Text="{Binding Input}" Placeholder="Ask about a request" />
<Button Grid.Column="1" Text="Send" Command="{Binding SendCommand}" />
</Grid>
</Grid>
</ContentPage>
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using FabrCore.Core;
// UI-independent: supply MainThread.BeginInvokeOnMainThread from the MAUI app.
public sealed class ChatViewModel : INotifyPropertyChanged
{
private readonly ChatConnection connection;
private readonly Action<Action> dispatch;
private readonly CancellationToken lifetime;
private readonly SendChatCommand send;
private string input = "";
private string? selectedAgent;
private string status = "Disconnected";
public ChatViewModel(ChatConnection connection, Action<Action> dispatch,
CancellationToken lifetime)
{
this.connection = connection;
this.dispatch = dispatch;
this.lifetime = lifetime;
send = new SendChatCommand(this);
}
public ObservableCollection<string> Agents { get; } = new();
public ObservableCollection<ChatLine> Messages { get; } = new();
public ICommand SendCommand => send;
public event PropertyChangedEventHandler? PropertyChanged;
public string Input
{
get => input;
set { input = value; Changed(); send.Refresh(); }
}
public string? SelectedAgent
{
get => selectedAgent;
set { selectedAgent = value; Changed(); send.Refresh(); }
}
public string Status
{
get => status;
private set { status = value; Changed(); }
}
// Populate only targets returned by the authenticated host/application.
public void SetAgents(IEnumerable<string> handles) => dispatch(() =>
{
Agents.Clear();
foreach (var handle in handles) Agents.Add(handle);
SelectedAgent = Agents.FirstOrDefault();
});
public void SetStatus(string value) => dispatch(() => Status = value);
// Call after the application's durable, account-bound deduplication transaction.
public void Add(AgentMessage message) => dispatch(() =>
{
if (message.MessageType is "_thinking" or "_status")
Status = message.Message ?? "Working…";
else if (message.MessageType == "_error")
Status = "The agent reported an error. Check the request before retrying.";
else
{
Messages.Add(new ChatLine("Assistant: " + message.Message));
Status = "Reply received";
}
});
private void Changed([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public sealed record ChatLine(string Text);
private sealed class SendChatCommand(ChatViewModel owner) : ICommand
{
private bool busy;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => !busy &&
!string.IsNullOrWhiteSpace(owner.SelectedAgent) &&
!string.IsNullOrWhiteSpace(owner.Input);
public void Refresh() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter)) return;
busy = true;
Refresh();
var text = owner.Input;
var target = owner.SelectedAgent!;
try
{
owner.Status = "Sending…";
await owner.connection.SendAsync(target, text, owner.lifetime);
owner.Messages.Add(new ChatLine("You: " + text));
if (owner.Input == text) owner.Input = "";
owner.Status = "Accepted; waiting for the agent";
}
catch (OperationCanceledException) { owner.Status = "Session ended"; }
catch (Exception)
{
// The server may have accepted the request before a network failure.
owner.Status = "Delivery uncertain. Reconnect and inspect history before resending.";
}
finally { busy = false; Refresh(); }
}
}
}
Exercise the screen's state transitions
- Bind the supplied view model to the page and populate its authorized agent choices. Select a provisioned echo agent and send “UI test SR-1042.”
- Confirm the composer clears only according to your send policy, a pending state is visible, and the matching response appears once. A progress/event envelope should not be mistaken for a second final answer.
- Disconnect and reconnect. The page should show an honest connection state and restore its transcript from application-owned state, rather than starting a second unobserved receiver on every render.
A usable chat screen exposes the real transport state while keeping message history independent of the current page instance.
If the result is different
Do not invent token streaming when the agent emits only a final message. Match UI capabilities to actual messages and keep secrets out of transcript storage.
Go deeper
Explore the related documentation.