Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using CommunityToolkit.Mvvm.ComponentModel;
using OneWare.Essentials.Models;

namespace OneWare.Chat.ViewModels.ChatMessages;

public class ChatMessageUserInputRequestViewModel : ObservableObject, IChatMessage
{
public ChatMessageUserInputRequestViewModel(ChatUserInputRequestEvent inputRequestEvent)
{
Event = inputRequestEvent;
}

public ChatUserInputRequestEvent Event { get; }

public string FreeformText
{
get;
set => SetProperty(ref field, value);
} = string.Empty;

public bool IsAnswered
{
get;
private set => SetProperty(ref field, value);
}

public string? AnswerText
{
get;
private set => SetProperty(ref field, value);
}

public void SelectChoice(string? choice) => Submit(choice ?? string.Empty);

public void SubmitFreeform() => Submit(FreeformText.Trim());

private void Submit(string answer)
{
if (IsAnswered || string.IsNullOrEmpty(answer)) return;

IsAnswered = true;
AnswerText = answer;
Event.SubmitCommand.Execute(answer);
}
}
159 changes: 135 additions & 24 deletions src/OneWare.Chat/ViewModels/ChatViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,16 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService
private readonly Dictionary<string, List<ChatSessionHistoryItem>> _historyByService = new(StringComparer.Ordinal);

private bool _initialized;
// Counts user messages sent locally so ChatUserMessageEvent duplicates are suppressed
private int _pendingLocalUserMessages;
// FIFO of messages sent locally so the echoed ChatUserMessageEvent can be matched
// (suppressed for normal/steered sends, or used to activate a queued message).
private readonly Queue<PendingLocalMessage> _pendingLocalMessages = new();

private const string DefaultWorkingStatus = "Working…";

private sealed record PendingLocalMessage(
string Content,
ChatSendMode Mode,
ChatMessageUserViewModel? QueuedView);

private static readonly JsonSerializerOptions ChatStateSerializerOptions = new()
{
Expand All @@ -60,7 +68,9 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma
AiFileEditService = aiFileEditService;

NewChatCommand = new AsyncRelayCommand(NewChatAsync);
SendCommand = new AsyncRelayCommand(SendAsync, CanSend);
SendCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Send), CanSend);
SteerCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Steer), CanSteerOrQueue);
QueueCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Queue), CanSteerOrQueue);
AbortCommand = new AsyncRelayCommand(AbortAsync, CanAbort);
InitializeCurrentCommand = new AsyncRelayCommand(InitializeCurrentAsync);

Expand All @@ -78,6 +88,8 @@ public string CurrentMessage
if (SetProperty(ref field, value))
{
SendCommand.NotifyCanExecuteChanged();
SteerCommand.NotifyCanExecuteChanged();
QueueCommand.NotifyCanExecuteChanged();
}
}
} = string.Empty;
Expand All @@ -90,6 +102,8 @@ public bool IsBusy
if (SetProperty(ref field, value))
{
SendCommand.NotifyCanExecuteChanged();
SteerCommand.NotifyCanExecuteChanged();
QueueCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
}
}
Expand All @@ -109,6 +123,8 @@ public bool IsConnected
if (SetProperty(ref field, value))
{
SendCommand.NotifyCanExecuteChanged();
SteerCommand.NotifyCanExecuteChanged();
QueueCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
}
}
Expand All @@ -122,6 +138,22 @@ public string StatusText

public ObservableCollection<IChatMessage> Messages { get; set; } = new();

/// <summary>
/// Messages the user has queued while the agent is busy. Rendered (dimmed) below the
/// conversation until the agent activates them.
/// </summary>
public ObservableCollection<IChatMessage> QueuedMessages { get; } = new();

/// <summary>
/// Text shown next to the working spinner. Switches to "Steering…" while a steered message
/// is being applied to the current turn.
/// </summary>
public string WorkingStatusText
{
get;
set => SetProperty(ref field, value);
} = DefaultWorkingStatus;

public ObservableCollection<IChatService> ChatServices { get; } = [];

public ObservableCollection<ChatSessionHistoryItem> SessionHistory { get; } = [];
Expand Down Expand Up @@ -172,6 +204,10 @@ public IChatService? SelectedChatService

public AsyncRelayCommand SendCommand { get; }

public AsyncRelayCommand SteerCommand { get; }

public AsyncRelayCommand QueueCommand { get; }

public AsyncRelayCommand AbortCommand { get; }

public AsyncRelayCommand InitializeCurrentCommand { get; }
Expand Down Expand Up @@ -231,11 +267,14 @@ private async Task NewChatAsync()
}

Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
WorkingStatusText = DefaultWorkingStatus;
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();
}

private async Task SendAsync()
private async Task SendInternalAsync(ChatSendMode mode)
{
var prompt = CurrentMessage.Trim();
if (string.IsNullOrWhiteSpace(prompt)) return;
Expand All @@ -258,14 +297,31 @@ private async Task SendAsync()
}

var userMessage = new ChatMessageUserViewModel(prompt);
var assistantMessage = new ChatMessageAssistantViewModel("init")
ChatMessageAssistantViewModel? assistantMessage = null;

switch (mode)
{
IsStreaming = true
};
case ChatSendMode.Queue:
// Park the message (dimmed) below the conversation until the agent activates it.
QueuedMessages.Add(userMessage);
_pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, userMessage));
break;

AddMessage(userMessage);
AddMessage(assistantMessage);
_pendingLocalUserMessages++;
case ChatSendMode.Steer:
AddMessage(userMessage);
_pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, null));
WorkingStatusText = "Steering…";
break;

default:
AddMessage(userMessage);
_pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, null));
// Only the initial (idle) send shows a placeholder; steered messages join the
// turn that is already streaming.
assistantMessage = new ChatMessageAssistantViewModel("init") { IsStreaming = true };
AddMessage(assistantMessage);
break;
}

CurrentMessage = string.Empty;
IsBusy = true;
Expand All @@ -274,24 +330,32 @@ private async Task SendAsync()

try
{
await SelectedChatService.SendAsync(prompt);
await SelectedChatService.SendAsync(prompt, mode);
}
catch (Exception ex)
{
if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
if (mode == ChatSendMode.Queue)
{
QueuedMessages.Remove(userMessage);
}
else if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
{
Messages.Remove(initMessage);
}
else
else if (assistantMessage != null)
{
Messages.Remove(assistantMessage);
}

AddErrorMessage(ex.Message);
IsBusy = false;
if (mode == ChatSendMode.Send) IsBusy = false;
if (mode == ChatSendMode.Steer) WorkingStatusText = DefaultWorkingStatus;
}
}

private bool CanSteerOrQueue() =>
IsConnected && IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage);

private async Task AbortAsync()
{
if (SelectedChatService == null) return;
Expand All @@ -306,10 +370,22 @@ private async Task AbortAsync()
}
}

private bool CanSend() => IsConnected && !IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage);
private bool CanSend() => IsConnected && !string.IsNullOrWhiteSpace(CurrentMessage);

private bool CanAbort() => IsConnected && IsBusy;

private bool TryDequeuePendingLocal(string content, out PendingLocalMessage pending)
{
if (_pendingLocalMessages.Count > 0 && _pendingLocalMessages.Peek().Content == content)
{
pending = _pendingLocalMessages.Dequeue();
return true;
}

pending = default!;
return false;
}

private void AddMessage(IChatMessage message)
{
if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
Expand Down Expand Up @@ -383,6 +459,8 @@ private void FinishTurn()
message.IsStreaming = false;

IsBusy = false;
// Safety: never let the steering indicator stick past the end of a turn.
WorkingStatusText = DefaultWorkingStatus;
});
}

Expand Down Expand Up @@ -442,15 +520,30 @@ private void OnEventReceived(object? sender, ChatEvent e)
}
case ChatUserMessageEvent x:
{
// If we sent this message locally it is already visible — skip.
// Otherwise it originates from a remote session user; show it and mark busy.
if (_pendingLocalUserMessages > 0)
{
_pendingLocalUserMessages--;
break;
}
Dispatcher.UIThread.Post(() =>
{
if (TryDequeuePendingLocal(x.Content, out var pending))
{
switch (pending.Mode)
{
case ChatSendMode.Queue when pending.QueuedView != null:
// The agent activated the queued message — promote it into the flow.
QueuedMessages.Remove(pending.QueuedView);
AddMessage(pending.QueuedView);
IsBusy = true;
ContentAdded?.Invoke(this, EventArgs.Empty);
break;
case ChatSendMode.Steer:
// Steering has been applied to the current turn.
WorkingStatusText = DefaultWorkingStatus;
break;
// Normal send: already visible, nothing to do.
}

return;
}

// Originates from a remote session user; show it and mark busy.
AddMessage(new ChatMessageUserViewModel(x.Content));
IsBusy = true;
ContentAdded?.Invoke(this, EventArgs.Empty);
Expand All @@ -459,7 +552,11 @@ private void OnEventReceived(object? sender, ChatEvent e)
}
case ChatButtonEvent x:
{
Dispatcher.UIThread.Post(() => { AddMessage(new ChatMessageWithButtonViewModel(x)); });
Dispatcher.UIThread.Post(() =>
{
AddMessage(new ChatMessageWithButtonViewModel(x));
ContentAdded?.Invoke(this, EventArgs.Empty);
});
break;
}
case ChatPermissionRequestEvent x:
Expand All @@ -469,12 +566,26 @@ private void OnEventReceived(object? sender, ChatEvent e)
var msg = new ChatMessagePermissionRequestViewModel(x);
msg.CloseAction = () => Messages.Remove(msg);
AddMessage(msg);
ContentAdded?.Invoke(this, EventArgs.Empty);
});
break;
}
case ChatUserInputRequestEvent x:
{
Dispatcher.UIThread.Post(() =>
{
AddMessage(new ChatMessageUserInputRequestViewModel(x));
ContentAdded?.Invoke(this, EventArgs.Empty);
});
break;
}
case ChatErrorEvent x:
{
Dispatcher.UIThread.Post(() => { AddErrorMessage(x.Message); });
Dispatcher.UIThread.Post(() =>
{
AddErrorMessage(x.Message);
ContentAdded?.Invoke(this, EventArgs.Empty);
});
break;
}
case ChatIdleEvent:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials"
xmlns:chatMessages="clr-namespace:OneWare.Chat.ViewModels.ChatMessages"
mc:Ignorable="d"
x:Class="OneWare.Chat.Views.ChatMessages.ChatMessageUserInputRequestView"
x:DataType="chatMessages:ChatMessageUserInputRequestViewModel">

<Border>
<StackPanel Spacing="6">
<controls:MarkdownViewer SelectionEnabled="True" Markdown="{Binding Event.Question}" />

<!-- Pending: choices + freeform input -->
<StackPanel Spacing="6" IsVisible="{Binding !IsAnswered}">
<ItemsControl ItemsSource="{Binding Event.Choices}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Button Height="NaN" Margin="0 0 6 6" Padding="5 3"
Classes="RoundButton"
Content="{Binding}"
CommandParameter="{Binding}"
Command="{Binding $parent[ItemsControl].((chatMessages:ChatMessageUserInputRequestViewModel)DataContext).SelectChoice}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

<Grid ColumnDefinitions="*, Auto" ColumnSpacing="6"
IsVisible="{Binding Event.AllowFreeform}">
<TextBox Grid.Column="0" Watermark="Type your answer…"
Text="{Binding FreeformText, Mode=TwoWay}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitFreeform}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1" Height="NaN" Padding="5 3"
Classes="RoundButton PrimaryButton"
Content="Send"
Command="{Binding SubmitFreeform}" />
</Grid>
</StackPanel>

<!-- Answered -->
<TextBlock IsVisible="{Binding IsAnswered}"
Foreground="{DynamicResource ThemeForegroundLowBrush}"
FontStyle="Italic"
Text="{Binding AnswerText, StringFormat='→ {0}'}" />
</StackPanel>
</Border>

</UserControl>
Loading
Loading