Size: 24.7 KB Modified: 3/07/2026 4:23 PM
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;
    }
}
Offline