Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/OneWare.Chat/Services/AiFunctionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
{
Id = id,
Result = exception == null,
ToolOutput = exception?.ToString()
ToolOutput = exception is OperationCanceledException ? "Cancelled." : exception?.ToString()
}));
}

Expand Down
97 changes: 96 additions & 1 deletion src/OneWare.Chat/ViewModels/ChatViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService
// 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 readonly List<CancelledQueuedMessage> _cancelledQueuedMessages = [];

private const string DefaultWorkingStatus = "Working…";

Expand All @@ -43,6 +44,8 @@ private sealed record PendingLocalMessage(
ChatSendMode Mode,
ChatMessageUserViewModel? QueuedView);

private sealed record CancelledQueuedMessage(string Content, DateTimeOffset ExpiresAt);

private static readonly JsonSerializerOptions ChatStateSerializerOptions = new()
{
WriteIndented = true,
Expand Down Expand Up @@ -73,8 +76,11 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma
SteerCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Steer), CanSteerOrQueue);
QueueCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Queue), CanSteerOrQueue);
AbortCommand = new AsyncRelayCommand(AbortAsync, CanAbort);
RemoveQueuedMessageCommand =
new AsyncRelayCommand<ChatMessageUserViewModel>(RemoveQueuedMessageAsync, CanRemoveQueuedMessage);
InitializeCurrentCommand = new AsyncRelayCommand(InitializeCurrentAsync);

QueuedMessages.CollectionChanged += (_, _) => RemoveQueuedMessageCommand.NotifyCanExecuteChanged();
applicationStateService.RegisterShutdownAction(SaveState);
}

Expand Down Expand Up @@ -211,6 +217,8 @@ public IChatService? SelectedChatService

public AsyncRelayCommand AbortCommand { get; }

public AsyncRelayCommand<ChatMessageUserViewModel> RemoveQueuedMessageCommand { get; }

public AsyncRelayCommand InitializeCurrentCommand { get; }

public override void InitializeContent()
Expand Down Expand Up @@ -270,6 +278,7 @@ private async Task NewChatAsync()
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
WorkingStatusText = DefaultWorkingStatus;
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();
Expand Down Expand Up @@ -338,6 +347,7 @@ private async Task SendInternalAsync(ChatSendMode mode)
if (mode == ChatSendMode.Queue)
{
QueuedMessages.Remove(userMessage);
RemovePendingLocalMessage(userMessage);
}
else if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
{
Expand All @@ -361,16 +371,56 @@ private async Task AbortAsync()
{
if (SelectedChatService == null) return;

var queueCleared = QueuedMessages.Count == 0;
if (!queueCleared)
{
try
{
queueCleared = await SelectedChatService.ClearQueuedMessagesAsync();
}
catch (Exception ex)
{
AddErrorMessage($"Failed to clear queued messages: {ex.Message}");
}
}

try
{
await SelectedChatService.AbortAsync();
}
finally
{
IsBusy = false;
if (queueCleared)
CancelQueuedMessagesLocally();

IsBusy = !queueCleared && QueuedMessages.Count > 0;
}
}

private async Task RemoveQueuedMessageAsync(ChatMessageUserViewModel? message)
{
if (message == null || SelectedChatService == null || !CanRemoveQueuedMessage(message)) return;

bool removed;
try
{
removed = await SelectedChatService.RemoveMostRecentQueuedMessageAsync();
}
catch (Exception ex)
{
AddErrorMessage($"Failed to remove queued message: {ex.Message}");
return;
}

if (!removed || !QueuedMessages.Contains(message)) return;

QueuedMessages.Remove(message);
RemovePendingLocalMessage(message);
}

private bool CanRemoveQueuedMessage(ChatMessageUserViewModel? message) =>
message != null && ReferenceEquals(QueuedMessages.LastOrDefault(), message);

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

private bool CanAbort() => IsConnected && IsBusy;
Expand All @@ -387,6 +437,43 @@ private bool TryDequeuePendingLocal(string content, out PendingLocalMessage pend
return false;
}

private bool TryDiscardCancelledQueuedMessage(string content)
{
var now = DateTimeOffset.Now;
_cancelledQueuedMessages.RemoveAll(x => x.ExpiresAt <= now);

var index = _cancelledQueuedMessages.FindIndex(x =>
string.Equals(x.Content, content, StringComparison.Ordinal));
if (index < 0) return false;

_cancelledQueuedMessages.RemoveAt(index);
return true;
}

private void CancelQueuedMessagesLocally()
{
var expiresAt = DateTimeOffset.Now.AddSeconds(30);
foreach (var pending in _pendingLocalMessages.Where(x => x.Mode == ChatSendMode.Queue))
_cancelledQueuedMessages.Add(new CancelledQueuedMessage(pending.Content, expiresAt));

var remaining = _pendingLocalMessages.Where(x => x.Mode != ChatSendMode.Queue).ToArray();
_pendingLocalMessages.Clear();
foreach (var pending in remaining)
_pendingLocalMessages.Enqueue(pending);

QueuedMessages.Clear();
}

private void RemovePendingLocalMessage(ChatMessageUserViewModel message)
{
var remaining = _pendingLocalMessages
.Where(x => !ReferenceEquals(x.QueuedView, message))
.ToArray();
_pendingLocalMessages.Clear();
foreach (var pending in remaining)
_pendingLocalMessages.Enqueue(pending);
}

private void AddMessage(IChatMessage message)
{
if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
Expand Down Expand Up @@ -544,6 +631,8 @@ private void OnEventReceived(object? sender, ChatEvent e)
return;
}

if (TryDiscardCancelledQueuedMessage(x.Content)) return;

// Originates from a remote session user; show it and mark busy.
AddMessage(new ChatMessageUserViewModel(x.Content));
IsBusy = true;
Expand Down Expand Up @@ -599,6 +688,9 @@ private void OnEventReceived(object? sender, ChatEvent e)
Dispatcher.UIThread.Post(() =>
{
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();
if (SelectedChatService != null)
Expand All @@ -623,6 +715,9 @@ private void OnSessionReset(object? sender, EventArgs e)
Dispatcher.UIThread.Post(() =>
{
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();

Expand Down
19 changes: 19 additions & 0 deletions src/OneWare.Chat/Views/ChatView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:OneWare.Chat.ViewModels"
xmlns:chatMessages="clr-namespace:OneWare.Chat.ViewModels.ChatMessages"
xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials"
xmlns:behaviors="clr-namespace:OneWare.Chat.Behaviors"
xmlns:behaviors1="clr-namespace:OneWare.Essentials.Behaviors;assembly=OneWare.Essentials"
Expand Down Expand Up @@ -93,6 +94,24 @@

<!-- Queued messages (dimmed, pinned below the conversation) -->
<ItemsControl Opacity="0.5" ItemsSource="{Binding QueuedMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="chatMessages:ChatMessageUserViewModel">
<Border Margin="0 2" HorizontalAlignment="Right" CornerRadius="6"
Padding="8 4 4 4" Background="{DynamicResource ThemeControlMidBrush}">
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="6">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="13"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Command="{Binding #ChatViewView.((viewModels:ChatViewModel)DataContext).RemoveQueuedMessageCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Remove queued message">
<Image Source="{DynamicResource MaterialDesign.Close}" Width="12"
Height="12" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="5" />
Expand Down
40 changes: 38 additions & 2 deletions src/OneWare.Chat/Views/ChatView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Threading;
using OneWare.Chat.ViewModels;
Expand Down Expand Up @@ -54,13 +55,30 @@ private void ScrollToEndDeferred()
Dispatcher.UIThread.Post(() => ScrollViewer.ScrollToEnd(), DispatcherPriority.Background);
}

private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e)
private async void OnCommandBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key is not (Key.Enter or Key.Return)) return;
if (DataContext is not ChatViewModel vm) return;

var modifiers = e.KeyModifiers;

var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.PlatformSettings?.HotkeyConfiguration.Paste.Any(gesture => gesture.Matches(e)) == true)
{
e.Handled = true;
try
{
await PasteClipboardAsync(vm, topLevel);
}
catch (TimeoutException)
{
// Match Avalonia TextBox behavior when the platform clipboard is temporarily busy.
}

return;
}

if (e.Key is not (Key.Enter or Key.Return)) return;

// Shift+Enter inserts a newline — let the TextBox handle it.
if (modifiers.HasFlag(KeyModifiers.Shift)) return;

Expand All @@ -81,6 +99,24 @@ private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e)
Execute(vm.IsBusy ? vm.SteerCommand : vm.SendCommand, e);
}

private async Task PasteClipboardAsync(ChatViewModel viewModel, TopLevel topLevel)
{
if (topLevel.Clipboard == null) return;

if (viewModel.SelectedChatService != null &&
await viewModel.SelectedChatService.TryAddClipboardAttachmentAsync(topLevel))
return;

var clipboardText = await topLevel.Clipboard.TryGetTextAsync();
if (string.IsNullOrEmpty(clipboardText)) return;

var text = CommandBox.Text ?? string.Empty;
var selectionStart = Math.Min(CommandBox.SelectionStart, CommandBox.SelectionEnd);
var selectionEnd = Math.Max(CommandBox.SelectionStart, CommandBox.SelectionEnd);
CommandBox.Text = text[..selectionStart] + clipboardText + text[selectionEnd..];
CommandBox.CaretIndex = selectionStart + clipboardText.Length;
}

private static void Execute(System.Windows.Input.ICommand command, KeyEventArgs e)
{
e.Handled = true;
Expand Down
Loading
Loading