From c43b52d483bd454ec7d50466bd97d94d90c69b82 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 11 Oct 2026 16:20:57 +0200 Subject: [PATCH] Fixed the warning about unsaved changes appearing only by chance (#1050) --- .../Components/ChatComponent.razor.cs | 12 +- .../Components/MSGComponentBase.cs | 10 +- .../Layout/MainLayout.razor.cs | 4 +- .../Tools/IMessageBusReceiver.cs | 6 +- app/MindWork AI Studio/Tools/MessageBus.cs | 29 +++- .../Tools/MessageBusAnswer.cs | 22 +++ .../Tools/Services/GlobalShortcutService.cs | 2 +- .../RustAvailabilityMonitorService.cs | 4 +- .../Tools/Services/UpdateService.cs | 4 +- .../wwwroot/changelog/v26.10.1.md | 1 + app/Tests/Tools/MessageBusTests.cs | 139 ++++++++++++++++++ 11 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/MessageBusAnswer.cs create mode 100644 app/Tests/Tools/MessageBusTests.cs diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 80860d9f..1f4941f1 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -2106,26 +2106,26 @@ public partial class ChatComponent : MSGComponentBase } } - protected override Task ProcessIncomingMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TResult : default where TPayload : default + protected override Task> ProcessIncomingMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) where TPayload : default { switch (triggeredEvent) { case Event.HAS_CHAT_UNSAVED_CHANGES: if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) - return Task.FromResult((TResult?) (object) false); + return Task.FromResult(new MessageBusAnswer(true, (TResult) (object) false)); if (this.IsCurrentChatStreaming) - return Task.FromResult((TResult?) (object) false); + return Task.FromResult(new MessageBusAnswer(true, (TResult) (object) false)); // // A message the user started counts as unsaved on its own only while there is no chat // to keep it. Otherwise, it is part of the chat: changing it counts as a change of the // chat, and a message which came back from the disk with the chat is saved already. // - return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || (this.ChatThread is null && this.ComposerState.HasVisibleUserDraft))); + return Task.FromResult(new MessageBusAnswer(true, (TResult)(object)(this.hasUnsavedChanges || (this.ChatThread is null && this.ComposerState.HasVisibleUserDraft)))); } - - return Task.FromResult(default(TResult)); + + return Task.FromResult(MessageBusAnswer.NONE); } #endregion diff --git a/app/MindWork AI Studio/Components/MSGComponentBase.cs b/app/MindWork AI Studio/Components/MSGComponentBase.cs index c98ecf57..c5209582 100644 --- a/app/MindWork AI Studio/Components/MSGComponentBase.cs +++ b/app/MindWork AI Studio/Components/MSGComponentBase.cs @@ -67,7 +67,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo }); } - public async Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + public async Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) { return await this.ProcessIncomingMessageWithResult(sendingComponent, triggeredEvent, data); } @@ -79,9 +79,13 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo return Task.CompletedTask; } - protected virtual Task ProcessIncomingMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + /// + /// Answers a question from the message bus. Override this for the questions your component handles, + /// and return MessageBusAnswer.NONE for every other one. + /// + protected virtual Task> ProcessIncomingMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) { - return Task.FromResult(default); + return Task.FromResult(MessageBusAnswer.NONE); } protected async Task SendMessage(Event triggeredEvent, T? data = default) diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 2ca84410..c9371c64 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -429,9 +429,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan } } - public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) { - return Task.FromResult(default); + return Task.FromResult(MessageBusAnswer.NONE); } #endregion diff --git a/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs index 019ce115..96e40fe6 100644 --- a/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs +++ b/app/MindWork AI Studio/Tools/IMessageBusReceiver.cs @@ -6,5 +6,9 @@ public interface IMessageBusReceiver { public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data); - public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data); + /// + /// Answers a question which was asked through MessageBus.SendMessageUseFirstResult. + /// + /// The answer, or MessageBusAnswer.NONE when this receiver does not handle the question. + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index c92785ee..a66c14ea 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -208,19 +208,42 @@ public sealed class MessageBus return messages; } + /// + /// Asks the receivers a question and returns the first answer. + /// + /// + /// Every receiver which never narrowed down its events gets the question as well, but only the ones + /// which handle it answer. All others return MessageBusAnswer.NONE and are passed over, cf. the remarks + /// on MessageBusAnswer for why a bare result cannot express that. + ///

+ /// A question stays within the circuit, i.e., the window, of the sender. The app keeps the circuits of + /// reloaded windows around for a long time, cf. the retention settings in Program.cs, and their components + /// are still registered: the chat of such an old window must not answer for the one the user is looking + /// at. Receivers without a circuit, such as hosted services, serve every window, and a sender without a + /// circuit asks all receivers. + ///
+ /// That's you, the sender. + /// The question to ask. + /// What the question needs to be answered, if anything. + /// The first answer, or the default of TResult when no receiver answers. Asking for unsaved + /// changes thus yields false when no chat is open. public async Task SendMessageUseFirstResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data = default) { + var senderCircuit = sendingComponent is IMessageBusReceiver sender && this.receiverCircuits.TryGetValue(sender, out var circuitState) ? circuitState : null; foreach (var (receiver, componentFilter) in this.componentFilters) { if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent)) continue; + if (senderCircuit is not null && this.receiverCircuits.TryGetValue(receiver, out var receiverCircuit) && !ReferenceEquals(receiverCircuit, senderCircuit)) + continue; + var eventFilter = this.componentEvents[receiver]; if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent)) { - var result = await receiver.ProcessMessageWithResult(sendingComponent, triggeredEvent, data); - if (result is not null) - return (TResult) result; + var answer = await receiver.ProcessMessageWithResult(sendingComponent, triggeredEvent, data); + if (answer.IsAnswered) + return answer.Value; } } diff --git a/app/MindWork AI Studio/Tools/MessageBusAnswer.cs b/app/MindWork AI Studio/Tools/MessageBusAnswer.cs new file mode 100644 index 00000000..de8c9897 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MessageBusAnswer.cs @@ -0,0 +1,22 @@ +namespace AIStudio.Tools; + +/// +/// What one receiver says to a question on the message bus, cf. MessageBus.SendMessageUseFirstResult. +/// +/// +/// A bare result cannot tell an answer apart from "this is not my question". Most receivers are components +/// which never narrow down their events, so every question reaches them, and the default they return looks +/// like an answer: for a bool, it is false. Such a false used to drown out the chat, the only receiver which +/// knows whether it has unsaved changes, and the warning about them appeared only by chance. That is why a +/// receiver states that it answers, and every other one returns NONE. +/// +/// True, when the receiver handles the question. +/// The answer. It means nothing unless IsAnswered is true. +/// The type of the answer. +public readonly record struct MessageBusAnswer(bool IsAnswered, TResult? Value) +{ + /// + /// The receiver does not handle this question. + /// + public static readonly MessageBusAnswer NONE = new(false, default); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index b082bfde..6ac6e277 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -145,7 +145,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } } - public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(default); + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer.NONE); #endregion diff --git a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs index caaf987f..14d77d04 100644 --- a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs @@ -91,9 +91,9 @@ public sealed class RustAvailabilityMonitorService : BackgroundService, IMessage return Task.CompletedTask; } - public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) { - return Task.FromResult(default); + return Task.FromResult(MessageBusAnswer.NONE); } private async Task VerifyRustAvailability() diff --git a/app/MindWork AI Studio/Tools/Services/UpdateService.cs b/app/MindWork AI Studio/Tools/Services/UpdateService.cs index 195155be..b624d4b1 100644 --- a/app/MindWork AI Studio/Tools/Services/UpdateService.cs +++ b/app/MindWork AI Studio/Tools/Services/UpdateService.cs @@ -81,9 +81,9 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver } } - public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) { - return Task.FromResult(default); + return Task.FromResult(MessageBusAnswer.NONE); } #endregion diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index c2589672..202bb4d0 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -39,6 +39,7 @@ - Fixed a chat you started by attaching an audio or video file getting a German name, regardless of the language you use AI Studio in. - Fixed AI Studio sometimes warning about unsaved changes when you left a chat in which you had only selected a chat template, without writing anything yourself. - Fixed regenerating an answer clearing the message you had started to write. +- Fixed the warning about unsaved changes in a chat appearing only by chance when you save your chats manually or have turned off workspaces. AI Studio now reliably asks before you open another chat, create a new one, or leave the chat page, so you no longer lose your changes by accident. - Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far. - Upgraded several libraries to improve security. - Upgraded to Rust v1.99.0 \ No newline at end of file diff --git a/app/Tests/Tools/MessageBusTests.cs b/app/Tests/Tools/MessageBusTests.cs new file mode 100644 index 00000000..bf609376 --- /dev/null +++ b/app/Tests/Tools/MessageBusTests.cs @@ -0,0 +1,139 @@ +using AIStudio.Tools; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks which answer the message bus returns when it asks its receivers a question. +/// +/// +/// Before the user leaves a chat or opens another one, AI Studio asks the chat whether it has unsaved +/// changes. Every component which never narrows down its events receives that question as well — one +/// for each block of each message in the chat, among others. Such a component has nothing to say about +/// it and must not drown out the one which has. Nor must the chat of another window: AI Studio keeps the +/// circuits of reloaded windows around, and their chats still listen. The bus walks its receivers in the +/// order of their hash codes, so the fakes here pin their hash codes, and each test checks both orders. +/// +[TestFixture] +[NonParallelizable] +public sealed class MessageBusTests +{ + private readonly List registeredReceivers = []; + + [TearDown] + public void UnregisterReceivers() + { + // + // The bus is static state of the whole application, which is why this fixture does not run + // alongside others, and why every receiver has to leave it again. + // + foreach (var receiver in this.registeredReceivers) + MessageBus.INSTANCE.Unregister(receiver); + + this.registeredReceivers.Clear(); + } + + [TestCase(true)] + [TestCase(false)] + public async Task AReceiverWithoutAnAnswerDoesNotDrownOutTheOneWithAnAnswer(bool isSilentReceiverAskedFirst) + { + this.Register(new SilentReceiver(isSilentReceiverAskedFirst ? 0 : 1)); + this.Register(new AnsweringReceiver(isSilentReceiverAskedFirst ? 1 : 0, true), Event.HAS_CHAT_UNSAVED_CHANGES); + + var hasUnsavedChanges = await MessageBus.INSTANCE.SendMessageUseFirstResult(null, Event.HAS_CHAT_UNSAVED_CHANGES); + + Assert.That(hasUnsavedChanges, Is.True, "Only the chat knows about its unsaved changes; a component which does not handle the question has nothing to say about them."); + } + + [Test] + public async Task AQuestionNobodyAnswersYieldsTheDefault() + { + this.Register(new SilentReceiver(0)); + this.Register(new SilentReceiver(1)); + + var hasUnsavedChanges = await MessageBus.INSTANCE.SendMessageUseFirstResult(null, Event.HAS_CHAT_UNSAVED_CHANGES); + + Assert.That(hasUnsavedChanges, Is.False, "Without a chat to answer, nothing is unsaved."); + } + + [TestCase(true)] + [TestCase(false)] + public async Task AQuestionIsAnsweredInTheWindowWhichAsksIt(bool isOtherWindowAskedFirst) + { + var askingWindow = new CircuitStateService(); + var otherWindow = new CircuitStateService(); + var askingComponent = new AskingComponent(); + this.RegisterInCircuit(askingComponent, askingWindow); + this.RegisterInCircuit(new AnsweringReceiver(isOtherWindowAskedFirst ? 1 : 0, false), askingWindow, Event.HAS_CHAT_UNSAVED_CHANGES); + this.RegisterInCircuit(new AnsweringReceiver(isOtherWindowAskedFirst ? 0 : 1, true), otherWindow, Event.HAS_CHAT_UNSAVED_CHANGES); + + var hasUnsavedChanges = await MessageBus.INSTANCE.SendMessageUseFirstResult(askingComponent, Event.HAS_CHAT_UNSAVED_CHANGES); + + Assert.That(hasUnsavedChanges, Is.False, "The chat of another window, such as one which was reloaded, knows nothing about the chat the user is looking at."); + } + + [Test] + public async Task AReceiverWithoutAWindowAnswersEveryWindow() + { + var askingWindow = new CircuitStateService(); + var askingComponent = new AskingComponent(); + this.RegisterInCircuit(askingComponent, askingWindow); + this.Register(new AnsweringReceiver(0, true), Event.HAS_CHAT_UNSAVED_CHANGES); + + var hasUnsavedChanges = await MessageBus.INSTANCE.SendMessageUseFirstResult(askingComponent, Event.HAS_CHAT_UNSAVED_CHANGES); + + Assert.That(hasUnsavedChanges, Is.True,"Services such as the update service live longer than any window, so they serve all of them."); + } + + private void Register(IMessageBusReceiver receiver, params Event[] events) => this.RegisterInCircuit(receiver, null, events); + + private void RegisterInCircuit(IMessageBusReceiver receiver, CircuitStateService? circuitState, params Event[] events) + { + MessageBus.INSTANCE.RegisterComponent(receiver, circuitState); + if (events.Length > 0) + MessageBus.INSTANCE.ApplyFilters(receiver, [], events.ToHashSet()); + + this.registeredReceivers.Add(receiver); + } + + /// + /// Asks the question, like the workspace list or the layout do. It is a receiver itself, which is how + /// the bus knows its window. + /// + private sealed class AskingComponent : ComponentBase, IMessageBusReceiver + { + public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) => Task.CompletedTask; + + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer.NONE); + } + + /// + /// Behaves like every component which never narrows down its events: it receives every question, + /// but handles none of them. + /// + /// Decides when the bus asks this receiver, see the remarks on the fixture. + private sealed class SilentReceiver(int hashCode) : IMessageBusReceiver + { + public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) => Task.CompletedTask; + + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer.NONE); + + public override int GetHashCode() => hashCode; + } + + /// + /// Behaves like the chat: it handles the question and answers it. + /// + /// Decides when the bus asks this receiver, see the remarks on the fixture. + /// The answer to every question. + private sealed class AnsweringReceiver(int hashCode, bool answer) : IMessageBusReceiver + { + public Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) => Task.CompletedTask; + + public Task> ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(new MessageBusAnswer(true, (TResult)(object)answer)); + + public override int GetHashCode() => hashCode; + } +} \ No newline at end of file