← Back to Files
ChatPage.cs
using System.Collections.ObjectModel;
using Microsoft.Maui.Controls.Shapes;
namespace KaiChat.Mobile;
public sealed class ChatPage : ContentPage
{
private readonly KaiChatClient _client;
private readonly OutboxService _outbox;
private readonly ObservableCollection<ConversationListItem> _conversations = new();
private readonly ObservableCollection<ChatItem> _messages = new();
private readonly CollectionView _messagesView;
private readonly CollectionView _conversationView;
private readonly Border _drawer;
private readonly Label _titleLabel;
private readonly Label _statusLabel;
private readonly Label _statusDetailLabel;
private readonly Editor _composer;
private readonly Button _sendButton;
private readonly Button _stopButton;
private string _currentConversationId = "";
private string _currentTitle = "Kai";
private ChatItem? _activeAssistant;
private bool _started;
private bool _streaming;
private bool _flushingOutbox;
private CancellationTokenSource? _connectLoopCts;
public ChatPage(KaiChatClient client, OutboxService outbox)
{
_client = client;
_outbox = outbox;
Title = "KaiChat";
BackgroundColor = Color.FromArgb("#0d1117");
_titleLabel = new Label
{
Text = _currentTitle,
TextColor = Color.FromArgb("#e6edf3"),
FontSize = 16,
FontAttributes = FontAttributes.Bold,
VerticalTextAlignment = TextAlignment.Center
};
_statusLabel = new Label
{
Text = "Offline",
TextColor = Color.FromArgb("#d29922"),
FontSize = 12,
FontAttributes = FontAttributes.Bold
};
_statusDetailLabel = new Label
{
Text = "",
TextColor = Color.FromArgb("#8b949e"),
FontSize = 12,
LineBreakMode = LineBreakMode.TailTruncation
};
_messagesView = new CollectionView
{
ItemsSource = _messages,
ItemTemplate = new DataTemplate(CreateMessageTemplate),
SelectionMode = SelectionMode.None,
ItemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical) { ItemSpacing = 10 },
BackgroundColor = Color.FromArgb("#0d1117")
};
_conversationView = new CollectionView
{
ItemsSource = _conversations,
ItemTemplate = new DataTemplate(CreateConversationTemplate),
SelectionMode = SelectionMode.Single,
BackgroundColor = Color.FromArgb("#161b22")
};
_conversationView.SelectionChanged += OnConversationSelected;
_composer = new Editor
{
Placeholder = "Type a message...",
PlaceholderColor = Color.FromArgb("#6e7681"),
TextColor = Color.FromArgb("#e6edf3"),
BackgroundColor = Color.FromArgb("#0d1117"),
AutoSize = EditorAutoSizeOption.TextChanges,
MinimumHeightRequest = 44,
MaximumHeightRequest = 140,
FontSize = 15,
Margin = new Thickness(0)
};
_sendButton = CreateActionButton("Send", Color.FromArgb("#58a6ff"));
_sendButton.Clicked += async (_, _) => await RunSafelyAsync(SendComposerAsync);
_stopButton = CreateActionButton("Stop", Color.FromArgb("#f85149"));
_stopButton.IsVisible = false;
_stopButton.Clicked += async (_, _) => await RunSafelyAsync(() => _client.StopStreamingAsync(_currentConversationId));
_drawer = BuildDrawer();
Content = BuildRoot();
WireClientEvents();
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (_started) return;
_started = true;
try
{
AppendQueuedMessages();
var serverUrl = Preferences.Default.Get("kaichat.serverUrl", "");
if (string.IsNullOrWhiteSpace(serverUrl))
serverUrl = await PromptForServerUrlAsync();
if (!string.IsNullOrWhiteSpace(serverUrl))
StartConnectLoop(serverUrl);
else
_statusDetailLabel.Text = "Set the KaiChat server URL to connect.";
}
catch (Exception ex)
{
_statusLabel.Text = "Offline";
_statusLabel.TextColor = Color.FromArgb("#f85149");
_statusDetailLabel.Text = ex.Message;
}
}
private Grid BuildRoot()
{
var chat = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Star },
new RowDefinition { Height = GridLength.Auto }
}
};
chat.Add(BuildTopBar(), 0, 0);
chat.Add(BuildStatusBar(), 0, 1);
chat.Add(_messagesView, 0, 2);
chat.Add(BuildComposer(), 0, 3);
var root = new Grid();
root.Add(chat);
root.Add(_drawer);
return root;
}
private Grid BuildTopBar()
{
var menuButton = CreateChromeButton("Menu");
menuButton.Clicked += (_, _) => _drawer.IsVisible = !_drawer.IsVisible;
var newButton = CreateChromeButton("+");
newButton.Clicked += (_, _) => NewChat();
var settingsButton = CreateChromeButton("Set");
settingsButton.Clicked += async (_, _) =>
{
await RunSafelyAsync(async () =>
{
var value = await PromptForServerUrlAsync();
if (!string.IsNullOrWhiteSpace(value))
StartConnectLoop(value);
});
};
var top = new Grid
{
Padding = new Thickness(10, 8),
BackgroundColor = Color.FromArgb("#161b22"),
ColumnDefinitions =
{
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Star },
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Auto }
}
};
top.Add(menuButton, 0, 0);
top.Add(_titleLabel, 1, 0);
top.Add(newButton, 2, 0);
top.Add(settingsButton, 3, 0);
return top;
}
private Grid BuildStatusBar()
{
var status = new Grid
{
Padding = new Thickness(12, 6),
BackgroundColor = Color.FromArgb("#0d1117"),
ColumnDefinitions =
{
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Star }
}
};
status.Add(_statusLabel, 0, 0);
status.Add(_statusDetailLabel, 1, 0);
return status;
}
private Grid BuildComposer()
{
var composer = new Grid
{
Padding = new Thickness(10, 8),
BackgroundColor = Color.FromArgb("#1c2333"),
ColumnSpacing = 8,
ColumnDefinitions =
{
new ColumnDefinition { Width = GridLength.Star },
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Auto }
}
};
composer.Add(_composer, 0, 0);
composer.Add(_sendButton, 1, 0);
composer.Add(_stopButton, 2, 0);
return composer;
}
private Border BuildDrawer()
{
var header = new Grid
{
Padding = new Thickness(12),
ColumnDefinitions =
{
new ColumnDefinition { Width = GridLength.Star },
new ColumnDefinition { Width = GridLength.Auto }
}
};
header.Add(new Label
{
Text = "Conversations",
TextColor = Color.FromArgb("#d29922"),
FontSize = 16,
FontAttributes = FontAttributes.Bold
}, 0, 0);
var close = CreateChromeButton("x");
close.Clicked += (_, _) => _drawer.IsVisible = false;
header.Add(close, 1, 0);
var panel = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Star }
},
BackgroundColor = Color.FromArgb("#161b22")
};
panel.Add(header, 0, 0);
panel.Add(_conversationView, 0, 1);
return new Border
{
Content = panel,
IsVisible = false,
WidthRequest = 320,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Fill,
BackgroundColor = Color.FromArgb("#161b22"),
Stroke = Color.FromArgb("#30363d"),
StrokeThickness = 1
};
}
private View CreateMessageTemplate()
{
var label = new Label
{
LineBreakMode = LineBreakMode.WordWrap,
FontSize = 15
};
label.SetBinding(Label.TextProperty, nameof(ChatItem.DisplayText));
label.SetBinding(Label.TextColorProperty, nameof(ChatItem.TextColor));
var bubble = new Border
{
Padding = new Thickness(12, 9),
MaximumWidthRequest = 340,
Stroke = Color.FromArgb("#30363d"),
StrokeShape = new RoundRectangle { CornerRadius = 10 },
Content = label,
Margin = new Thickness(10, 0)
};
bubble.SetBinding(VisualElement.BackgroundColorProperty, nameof(ChatItem.BubbleColor));
bubble.SetBinding(View.HorizontalOptionsProperty, nameof(ChatItem.BubbleAlignment));
return bubble;
}
private View CreateConversationTemplate()
{
var title = new Label
{
TextColor = Color.FromArgb("#e6edf3"),
FontSize = 14,
FontAttributes = FontAttributes.Bold,
LineBreakMode = LineBreakMode.TailTruncation
};
title.SetBinding(Label.TextProperty, nameof(ConversationListItem.Title));
var subtitle = new Label
{
TextColor = Color.FromArgb("#8b949e"),
FontSize = 12,
LineBreakMode = LineBreakMode.TailTruncation
};
subtitle.SetBinding(Label.TextProperty, nameof(ConversationListItem.Subtitle));
return new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
Children = { title, subtitle }
};
}
private static Button CreateChromeButton(string text)
{
return new Button
{
Text = text,
BackgroundColor = Colors.Transparent,
TextColor = Color.FromArgb("#e6edf3"),
FontSize = 18,
Padding = new Thickness(10, 4),
MinimumWidthRequest = 42
};
}
private static Button CreateActionButton(string text, Color color)
{
return new Button
{
Text = text,
BackgroundColor = color,
TextColor = Colors.White,
FontAttributes = FontAttributes.Bold,
CornerRadius = 8,
Padding = new Thickness(14, 8)
};
}
private void WireClientEvents()
{
_client.ConnectionChanged += OnConnectionChanged;
_client.StreamReplayStarted += OnStreamReplayStarted;
_client.StreamStarted += OnStreamStarted;
_client.TokenReceived += OnTokenReceived;
_client.ThinkingReceived += text => _statusDetailLabel.Text = "Thinking...";
_client.ResponseTranslationStarted += OnResponseTranslationStarted;
_client.ResponseTranslationDelta += OnResponseTranslationDelta;
_client.ResponseTranslated += OnResponseTranslated;
_client.StreamCompleted += OnStreamCompleted;
_client.StreamCancelled += () =>
{
_streaming = false;
SetStreamingControls(false);
_statusDetailLabel.Text = "Stopped";
_ = FlushOutboxAsync();
};
_client.ErrorReceived += error =>
{
_streaming = false;
SetStreamingControls(false);
_statusDetailLabel.Text = error;
_ = FlushOutboxAsync();
};
_client.ConversationCreated += OnConversationCreated;
_client.UserMessageSaved += OnUserMessageSaved;
_client.ConversationRenamed += (id, title) =>
{
if (id == _currentConversationId) SetCurrentTitle(title);
_ = LoadConversationsAsync();
};
_client.ConversationsChanged += () => _ = LoadConversationsAsync();
_client.ToolCallReceived += status => _statusDetailLabel.Text = status;
}
private void OnConnectionChanged(string state, string? detail)
{
_statusLabel.Text = state;
_statusLabel.TextColor = state switch
{
"Connected" => Color.FromArgb("#3fb950"),
"Offline" => Color.FromArgb("#f85149"),
_ => Color.FromArgb("#d29922")
};
_statusDetailLabel.Text = detail ?? "";
if (state == "Connected")
{
if (!string.IsNullOrWhiteSpace(_currentConversationId))
_ = _client.JoinConversationAsync(_currentConversationId);
_ = LoadConversationsAsync();
_ = FlushOutboxAsync();
}
}
private void OnStreamReplayStarted(string conversationId)
{
if (!string.IsNullOrWhiteSpace(_currentConversationId) && conversationId != _currentConversationId)
return;
RemoveOpenAssistant();
_streaming = true;
SetStreamingControls(true);
}
private void OnStreamStarted()
{
_streaming = true;
SetStreamingControls(true);
_activeAssistant = null;
_statusDetailLabel.Text = "Streaming...";
}
private void OnTokenReceived(string token)
{
var assistant = EnsureAssistant();
assistant.Text += token;
ScrollToBottom();
}
private void OnResponseTranslationStarted()
{
var assistant = EnsureAssistant();
assistant.Text = "";
_statusDetailLabel.Text = "Translating...";
ScrollToBottom();
}
private void OnResponseTranslationDelta(string token)
{
var assistant = EnsureAssistant();
assistant.Text += token;
ScrollToBottom();
}
private void OnResponseTranslated(string text)
{
var assistant = EnsureAssistant();
assistant.Text = text;
ScrollToBottom();
}
private async void OnStreamCompleted()
{
try
{
if (_activeAssistant != null)
{
_activeAssistant.IsClosed = true;
_activeAssistant = null;
}
_streaming = false;
SetStreamingControls(false);
_statusDetailLabel.Text = "Ready";
await LoadConversationsAsync();
if (!string.IsNullOrWhiteSpace(_currentConversationId))
await LoadConversationAsync(_currentConversationId);
await FlushOutboxAsync();
}
catch (Exception ex)
{
_statusDetailLabel.Text = ex.Message;
}
}
private void OnConversationCreated(Conversation conversation)
{
_currentConversationId = conversation.Id;
SetCurrentTitle(conversation.Title);
_ = _client.JoinConversationAsync(_currentConversationId);
_ = LoadConversationsAsync();
}
private void OnUserMessageSaved(string conversationId, ChatMessageDto message)
{
if (!string.IsNullOrWhiteSpace(_currentConversationId) &&
!string.IsNullOrWhiteSpace(conversationId) &&
conversationId != _currentConversationId)
{
return;
}
if (string.IsNullOrWhiteSpace(_currentConversationId) &&
!string.IsNullOrWhiteSpace(conversationId))
{
_currentConversationId = conversationId;
}
var id = string.IsNullOrWhiteSpace(message.Id)
? Guid.NewGuid().ToString("N")[..8]
: message.Id;
var existing = _messages.FirstOrDefault(m => m.Id == id);
if (existing != null)
{
existing.Text = message.Content;
existing.IsClosed = true;
return;
}
AppendUserMessage(message.Content, id, queued: false);
}
private async void OnConversationSelected(object? sender, SelectionChangedEventArgs e)
{
try
{
if (e.CurrentSelection.FirstOrDefault() is not ConversationListItem item) return;
_drawer.IsVisible = false;
_conversationView.SelectedItem = null;
await LoadConversationAsync(item.Id);
}
catch (Exception ex)
{
_statusDetailLabel.Text = ex.Message;
}
}
private async Task SendComposerAsync()
{
var text = _composer.Text?.Trim();
if (string.IsNullOrWhiteSpace(text)) return;
_composer.Text = "";
var message = new OutboxMessage
{
ConversationId = _currentConversationId,
Text = text,
Title = string.IsNullOrWhiteSpace(_currentConversationId) ? MakeTitle(text) : null
};
AppendUserMessage(text, message.MessageId, queued: !_client.IsConnected);
if (!_client.IsConnected || _streaming)
{
_outbox.Enqueue(message);
_statusDetailLabel.Text = "Queued";
return;
}
await SendMessageNowAsync(message);
}
private async Task SendMessageNowAsync(OutboxMessage message)
{
_streaming = true;
SetStreamingControls(true);
_statusDetailLabel.Text = "Sending...";
try
{
await _client.SendMessageAsync(message);
}
catch
{
_outbox.Enqueue(message);
_streaming = false;
SetStreamingControls(false);
StartConnectLoop(_client.ServerUrl);
}
}
private async Task FlushOutboxAsync()
{
if (_flushingOutbox || _streaming || !_client.IsConnected) return;
_flushingOutbox = true;
try
{
while (!_streaming && _client.IsConnected)
{
var queued = _outbox.Load();
if (queued.Count == 0) break;
var next = queued[0];
queued.RemoveAt(0);
if (string.IsNullOrWhiteSpace(next.ConversationId))
next.ConversationId = _currentConversationId;
_outbox.Save(queued);
await SendMessageNowAsync(next);
}
}
finally
{
_flushingOutbox = false;
}
}
private async Task LoadConversationsAsync()
{
if (string.IsNullOrWhiteSpace(_client.ServerUrl)) return;
try
{
var conversations = await _client.GetConversationsAsync();
_conversations.Clear();
foreach (var c in conversations)
{
_conversations.Add(new ConversationListItem
{
Id = c.Id,
Title = string.IsNullOrWhiteSpace(c.Title) ? "Chat" : c.Title,
Subtitle = $"{c.MessageCount} messages"
});
}
}
catch (Exception ex)
{
_statusDetailLabel.Text = ex.Message;
}
}
private async Task LoadConversationAsync(string id)
{
try
{
var conversation = await _client.GetConversationAsync(id);
if (conversation == null) return;
_currentConversationId = conversation.Id;
SetCurrentTitle(conversation.Title);
_messages.Clear();
_activeAssistant = null;
foreach (var message in conversation.Messages)
{
if (message.Role == "system") continue;
_messages.Add(new ChatItem
{
Id = message.Id,
Role = message.Role,
Text = message.Content,
IsClosed = true
});
}
AppendQueuedMessages();
await _client.JoinConversationAsync(id);
ScrollToBottom();
}
catch (Exception ex)
{
_statusDetailLabel.Text = ex.Message;
}
}
private void NewChat()
{
_currentConversationId = "";
SetCurrentTitle("Kai");
_messages.Clear();
AppendQueuedMessages();
}
private void AppendQueuedMessages()
{
foreach (var queued in _outbox.Load())
{
if (!string.IsNullOrWhiteSpace(queued.ConversationId) &&
queued.ConversationId != _currentConversationId)
{
continue;
}
if (_messages.Any(m => m.Id == queued.MessageId)) continue;
AppendUserMessage(queued.Text + "\n\n(queued)", queued.MessageId, queued: true);
}
}
private void AppendUserMessage(string text, string id, bool queued)
{
_messages.Add(new ChatItem
{
Id = id,
Role = "user",
Text = text,
IsClosed = !queued
});
ScrollToBottom();
}
private ChatItem EnsureAssistant()
{
if (_activeAssistant != null) return _activeAssistant;
_activeAssistant = new ChatItem
{
Role = "assistant",
Text = "",
IsClosed = false
};
_messages.Add(_activeAssistant);
return _activeAssistant;
}
private void RemoveOpenAssistant()
{
var open = _messages.Where(m => m.Role == "assistant" && !m.IsClosed).ToList();
foreach (var item in open)
_messages.Remove(item);
_activeAssistant = null;
}
private void SetStreamingControls(bool streaming)
{
_streaming = streaming;
_sendButton.IsEnabled = !streaming;
_stopButton.IsVisible = streaming;
}
private void SetCurrentTitle(string? title)
{
_currentTitle = string.IsNullOrWhiteSpace(title) ? "Kai" : title;
_titleLabel.Text = _currentTitle;
}
private void ScrollToBottom()
{
if (_messages.Count == 0) return;
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
_messagesView.ScrollTo(_messages[^1], position: ScrollToPosition.End, animate: true);
}
catch
{
}
});
}
private async Task<string> PromptForServerUrlAsync()
{
var current = Preferences.Default.Get("kaichat.serverUrl", "");
var value = await DisplayPromptAsync(
"KaiChat Server",
"Enter your KaiChat URL or Tailscale IP:port",
"Save",
"Cancel",
"http://100.x.y.z:5200",
keyboard: Keyboard.Url,
initialValue: current);
if (!string.IsNullOrWhiteSpace(value))
Preferences.Default.Set("kaichat.serverUrl", value.Trim());
return value?.Trim() ?? "";
}
private void StartConnectLoop(string serverUrl)
{
_connectLoopCts?.Cancel();
var cts = new CancellationTokenSource();
_connectLoopCts = cts;
_ = Task.Run(async () =>
{
try
{
await _client.ConfigureAsync(serverUrl);
while (!_client.IsConnected && !cts.IsCancellationRequested)
{
try
{
await _client.ConnectAsync();
}
catch
{
await Task.Delay(TimeSpan.FromSeconds(3), cts.Token);
}
}
}
catch (OperationCanceledException) {}
catch (Exception ex)
{
MainThread.BeginInvokeOnMainThread(() =>
{
_statusLabel.Text = "Offline";
_statusLabel.TextColor = Color.FromArgb("#f85149");
_statusDetailLabel.Text = ex.Message;
});
}
finally
{
if (_connectLoopCts == cts)
_connectLoopCts = null;
}
});
}
private async Task RunSafelyAsync(Func<Task> action)
{
try
{
await action();
}
catch (Exception ex)
{
_statusLabel.Text = "Offline";
_statusLabel.TextColor = Color.FromArgb("#f85149");
_statusDetailLabel.Text = ex.Message;
}
}
private static string MakeTitle(string text)
{
return text.Length > 40 ? text[..40] + "..." : text;
}
}