From 9f78780aba7a9d8b588740f1c7252c93cfe8dfe6 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 24 Jun 2026 13:29:28 +0200 Subject: [PATCH 1/7] steering + queued --- src/OneWare.Chat/ViewModels/ChatViewModel.cs | 51 +++++++-- src/OneWare.Chat/Views/ChatView.axaml | 35 +++++- src/OneWare.Copilot/CopilotModule.cs | 3 + src/OneWare.Copilot/OneWare.Copilot.csproj | 4 - .../Services/CopilotChatService.cs | 104 +++++++++++++++++- .../Views/CopilotChatExtensionView.axaml | 37 ++++++- src/OneWare.Essentials/Enums/ChatSendMode.cs | 22 ++++ .../Services/IChatService.cs | 16 +++ 8 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 src/OneWare.Essentials/Enums/ChatSendMode.cs diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index acdb1c97c..5892189be 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -60,7 +60,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); @@ -78,6 +80,8 @@ public string CurrentMessage if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); } } } = string.Empty; @@ -90,6 +94,8 @@ public bool IsBusy if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged(); } } @@ -109,6 +115,8 @@ public bool IsConnected if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged(); } } @@ -172,6 +180,10 @@ public IChatService? SelectedChatService public AsyncRelayCommand SendCommand { get; } + public AsyncRelayCommand SteerCommand { get; } + + public AsyncRelayCommand QueueCommand { get; } + public AsyncRelayCommand AbortCommand { get; } public AsyncRelayCommand InitializeCurrentCommand { get; } @@ -235,7 +247,7 @@ private async Task NewChatAsync() _assistantReasoningById.Clear(); } - private async Task SendAsync() + private async Task SendInternalAsync(ChatSendMode mode) { var prompt = CurrentMessage.Trim(); if (string.IsNullOrWhiteSpace(prompt)) return; @@ -257,14 +269,28 @@ private async Task SendAsync() return; } - var userMessage = new ChatMessageUserViewModel(prompt); - var assistantMessage = new ChatMessageAssistantViewModel("init") + if (mode == ChatSendMode.Send && IsBusy) { - IsStreaming = true - }; + mode = ChatSendMode.Send; + } + + var steering = mode is ChatSendMode.Steer or ChatSendMode.Queue; + var userMessage = new ChatMessageUserViewModel(prompt); AddMessage(userMessage); - AddMessage(assistantMessage); + + ChatMessageAssistantViewModel? assistantMessage = null; + if (!steering) + { + // Only the initial (idle) send shows a placeholder; steered/queued messages + // join the turn that is already streaming. + assistantMessage = new ChatMessageAssistantViewModel("init") + { + IsStreaming = true + }; + AddMessage(assistantMessage); + } + _pendingLocalUserMessages++; CurrentMessage = string.Empty; @@ -274,7 +300,7 @@ private async Task SendAsync() try { - await SelectedChatService.SendAsync(prompt); + await SelectedChatService.SendAsync(prompt, mode); } catch (Exception ex) { @@ -282,16 +308,19 @@ private async Task SendAsync() { Messages.Remove(initMessage); } - else + else if (assistantMessage != null) { Messages.Remove(assistantMessage); } AddErrorMessage(ex.Message); - IsBusy = false; + if (!steering) IsBusy = false; } } + private bool CanSteerOrQueue() => + IsConnected && IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage); + private async Task AbortAsync() { if (SelectedChatService == null) return; @@ -306,7 +335,7 @@ private async Task AbortAsync() } } - private bool CanSend() => IsConnected && !IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage); + private bool CanSend() => IsConnected && !string.IsNullOrWhiteSpace(CurrentMessage); private bool CanAbort() => IsConnected && IsBusy; diff --git a/src/OneWare.Chat/Views/ChatView.axaml b/src/OneWare.Chat/Views/ChatView.axaml index b384fbe37..644e8af68 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml +++ b/src/OneWare.Chat/Views/ChatView.axaml @@ -178,8 +178,10 @@ VerticalContentAlignment="Top" Text="{Binding CurrentMessage, Mode=TwoWay}"> - + + @@ -191,6 +193,35 @@ + + + + + + + + diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index b5c13bed5..ec874e155 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -13,6 +13,7 @@ public class CopilotModule : OneWareModuleBase public const string CopilotCliSettingKey = "AI_Chat_Copilot_CLI"; public const string CopilotSelectedModelSettingKey = "AI_Chat_Copilot_SelectedModel"; + public const string CopilotSelectedReasoningEffortSettingKey = "AI_Chat_Copilot_SelectedReasoningEffort"; public const string CopilotAutopilotSettingKey = "AI_Chat_Copilot_Autopilot"; public static readonly Package CopilotPackage = new() @@ -175,6 +176,8 @@ public override void Initialize(IServiceProvider serviceProvider) serviceProvider.Resolve().Register(CopilotSelectedModelSettingKey, "gpt-5-mini"); + serviceProvider.Resolve().Register(CopilotSelectedReasoningEffortSettingKey, ""); + serviceProvider.Resolve() .RegisterChatService(serviceProvider.Resolve()); } diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj index d0d30c4e4..0f270a8e7 100644 --- a/src/OneWare.Copilot/OneWare.Copilot.csproj +++ b/src/OneWare.Copilot/OneWare.Copilot.csproj @@ -14,9 +14,5 @@ - - - - \ No newline at end of file diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index c96810447..453a39383 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -138,16 +138,97 @@ public ModelInfo? SelectedModel if (SetProperty(ref field, value) && value != null) { settingsService.SetSettingValue(CopilotModule.CopilotSelectedModelSettingKey, value.Id); + RefreshReasoningEfforts(value); if (oldValue != null && oldValue.Id != value.Id) { - // When a model is changed we reset the session and force a new one on next init - SessionReset?.Invoke(this, EventArgs.Empty); - _ = NewChatAsync(); + // Switch the model in place for the next message, preserving conversation history. + ApplyModelToSession(); } } } } + public ObservableCollection ReasoningEfforts { get; } = []; + + public bool ShowReasoningEffort + { + get; + private set => SetProperty(ref field, value); + } + + public string? SelectedReasoningEffort + { + get; + set + { + if (SetProperty(ref field, value) && value != null && !_suppressReasoningEffortApply) + { + settingsService.SetSettingValue(CopilotModule.CopilotSelectedReasoningEffortSettingKey, value); + ApplyModelToSession(); + } + } + } + + private bool _suppressReasoningEffortApply; + + private void RefreshReasoningEfforts(ModelInfo model) + { + var supported = model.Capabilities.Supports.ReasoningEffort + ? model.SupportedReasoningEfforts ?? [] + : []; + + _suppressReasoningEffortApply = true; + try + { + ReasoningEfforts.Clear(); + foreach (var effort in supported) ReasoningEfforts.Add(effort); + + ShowReasoningEffort = ReasoningEfforts.Count > 0; + + if (!ShowReasoningEffort) + { + SelectedReasoningEffort = null; + return; + } + + var persisted = + settingsService.GetSettingValue(CopilotModule.CopilotSelectedReasoningEffortSettingKey); + + SelectedReasoningEffort = + !string.IsNullOrEmpty(persisted) && ReasoningEfforts.Contains(persisted) + ? persisted + : model.DefaultReasoningEffort is { } def && ReasoningEfforts.Contains(def) + ? def + : ReasoningEfforts.FirstOrDefault(); + } + finally + { + _suppressReasoningEffortApply = false; + } + } + + private void ApplyModelToSession() + { + var session = _session; + var model = SelectedModel; + if (session == null || model == null) return; + + var effort = ShowReasoningEffort ? SelectedReasoningEffort : null; + + _ = Task.Run(async () => + { + try + { + await session.SetModelAsync(model.Id, effort); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + .LogError(ex, "Failed to switch Copilot model to {Model}.", model.Id); + } + }); + } + public string Name { get; } = "Copilot"; public string? CurrentSessionId @@ -370,6 +451,7 @@ private async Task InitializeSessionAsync() var sessionConfig = new SessionConfig { Model = SelectedModel.Id, + ReasoningEffort = ShowReasoningEffort ? SelectedReasoningEffort : null, Streaming = true, // Only stream root-agent deltas; the chat UI does not differentiate sub-agents. IncludeSubAgentStreamingEvents = false, @@ -509,7 +591,9 @@ private SystemMessageConfig BuildSystemMessageConfig() }; } - public async Task SendAsync(string prompt) + public Task SendAsync(string prompt) => SendAsync(prompt, ChatSendMode.Send); + + public async Task SendAsync(string prompt, ChatSendMode mode) { if (SelectedModel == null) { @@ -524,7 +608,17 @@ public async Task SendAsync(string prompt) } if (_session == null) return; - await _session.SendAsync(new MessageOptions { Prompt = prompt }).ConfigureAwait(false); + + var options = new MessageOptions { Prompt = prompt }; + var sdkMode = mode switch + { + ChatSendMode.Steer => "immediate", + ChatSendMode.Queue => "enqueue", + _ => null + }; + if (sdkMode != null) options.Mode = sdkMode; + + await _session.SendAsync(options).ConfigureAwait(false); } public async Task AbortAsync() diff --git a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml index b51f1ceab..32b675ade 100644 --- a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml @@ -61,7 +61,42 @@ - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs new file mode 100644 index 000000000..68292cf9c --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace OneWare.Copilot.Views; + +public partial class CopilotChatAttachmentsView : UserControl +{ + public CopilotChatAttachmentsView() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml index 32b675ade..33b05194c 100644 --- a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml @@ -184,7 +184,7 @@ StrokeThickness="2.5" TrackBrush="{DynamicResource ThemeBorderMidBrush}" ProgressBrush="{DynamicResource ThemeAccentBrush}" - Width="18" Height="18" + Width="14" Height="14" IsVisible="{Binding ContextTokenLimit, Converter={x:Static ObjectConverters.IsNotNull}}" ToolTip.Tip="{Binding ContextCurrentTokens, StringFormat='Context: {0:N0} tokens'}" /> diff --git a/src/OneWare.Core/Styles/Icons.axaml b/src/OneWare.Core/Styles/Icons.axaml index bd1f47fec..5d5fb2681 100644 --- a/src/OneWare.Core/Styles/Icons.axaml +++ b/src/OneWare.Core/Styles/Icons.axaml @@ -8,7 +8,7 @@ Geometry="{DynamicResource FluentIconsFilled.LightbulbFilled.Geometry}" /> - + @@ -340,6 +340,12 @@ Geometry="M16,6.857 L9.143,6.857 L9.143,-2.093E-007 L6.857,-2.093E-007 L6.857,6.857 L9.809E-009,6.857 L9.809E-009,9.143 L6.857,9.143 L6.857,16 L9.143,16 L9.143,9.143 L16,9.143 z" /> + + + + + @@ -1087,7 +1093,7 @@ Geometry="F1M3.9699,2.9698L1.9999,0.999799999999999 0.9399,2.0608 2.9089,4.0298 0.9399,5.9998 1.9999,7.0598 3.9699,5.0908 5.9389,7.0598 6.9999,5.9998 5.0299,4.0298 6.9999,2.0608 5.9389,0.999799999999999z" /> - + diff --git a/src/OneWare.Essentials/Services/IChatService.cs b/src/OneWare.Essentials/Services/IChatService.cs index 70c5ca2dd..706387e18 100644 --- a/src/OneWare.Essentials/Services/IChatService.cs +++ b/src/OneWare.Essentials/Services/IChatService.cs @@ -16,6 +16,11 @@ public interface IChatService : INotifyPropertyChanged, IAsyncDisposable /// public Control? BottomUiExtension { get; } + /// + /// Optional UI extension displayed above the chat input area (e.g. attachments). + /// + public Control? TopUiExtension => null; + /// /// Fired when the chat session is reset. /// From ed7f05f53713f1498bf2ec194b9cb68aaef22806 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 24 Jun 2026 15:25:18 +0200 Subject: [PATCH 5/7] fix userinput --- .../ChatMessageUserInputRequestViewModel.cs | 45 +++++++++++++++ src/OneWare.Chat/ViewModels/ChatViewModel.cs | 5 ++ .../ChatMessageUserInputRequestView.axaml | 57 +++++++++++++++++++ .../ChatMessageUserInputRequestView.axaml.cs | 11 ++++ .../Services/CopilotChatService.cs | 52 +++++++++++++++-- .../Models/ChatServiceEvents.cs | 22 +++++++ 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml.cs diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs new file mode 100644 index 000000000..7c5796286 --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs @@ -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); + } +} diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 40f88016c..d07938a4a 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -565,6 +565,11 @@ private void OnEventReceived(object? sender, ChatEvent e) }); break; } + case ChatUserInputRequestEvent x: + { + Dispatcher.UIThread.Post(() => AddMessage(new ChatMessageUserInputRequestViewModel(x))); + break; + } case ChatErrorEvent x: { Dispatcher.UIThread.Post(() => { AddErrorMessage(x.Message); }); diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml new file mode 100644 index 000000000..0f2c00657 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + +