diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 3cd6a56f..21ffa78c 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -3664,6 +3664,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0}
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
+-- Profile changes are locked while a response is running.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profile changes are locked while a response is running."
+
+-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished."
+
-- Start new chat in workspace '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'"
@@ -5524,6 +5530,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspac
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost."
+-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."
+
-- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name"
diff --git a/app/MindWork AI Studio/Chat/ChatBlockActions.cs b/app/MindWork AI Studio/Chat/ChatBlockActions.cs
new file mode 100644
index 00000000..cf35c5cd
--- /dev/null
+++ b/app/MindWork AI Studio/Chat/ChatBlockActions.cs
@@ -0,0 +1,105 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace AIStudio.Chat;
+
+///
+/// Changes the messages of a chat on behalf of the user, but never while an answer is being written for it.
+///
+///
+/// While an answer is being written, the AI job holds the very same thread as the chat: it writes into
+/// the answer, and it stores the whole conversation every few seconds. A message the user removed in
+/// the meantime does not come back, but the job goes on writing an answer nobody sees, and the chat
+/// stays locked until that answer is complete. Removing the answer and sending another message would
+/// leave two answers being written for one chat. Changing older messages is no better: the job stores
+/// the conversation on another thread and could read it in the middle of the change.
+///
+/// That is why every message is locked while an answer is being written, not only the answer itself.
+/// Stopping the answer unlocks the chat right away.
+///
+/// Whether an answer is being written for the chat with the given ID right now.
+public sealed class ChatBlockActions(Func isAnswerBeingWritten)
+{
+ ///
+ /// Whether the user may change the messages of the given chat right now.
+ ///
+ /// The chat, or null when there is none yet.
+ /// True when there is a chat and no answer is being written for it.
+ public bool AreAllowed([NotNullWhen(true)] ChatThread? chat) => chat is not null && !isAnswerBeingWritten(chat.ChatId);
+
+ ///
+ /// Removes a message, together with the hidden prompt an answer was written for.
+ ///
+ /// The chat.
+ /// The content of the message to remove.
+ /// True when the message was removed.
+ public bool TryRemove([NotNullWhen(true)] ChatThread? chat, IContent content)
+ {
+ if (!this.AreAllowed(chat) || !Holds(chat, content))
+ return false;
+
+ chat.Remove(content);
+ return true;
+ }
+
+ ///
+ /// Removes an answer so that it can be written anew for the same question.
+ ///
+ /// The chat.
+ /// The content of the answer to remove.
+ /// True when the answer was removed.
+ public bool TryRemoveForRegenerate([NotNullWhen(true)] ChatThread? chat, IContent answer)
+ {
+ if (!this.AreAllowed(chat) || !Holds(chat, answer))
+ return false;
+
+ chat.Remove(answer, removeForRegenerate: true);
+ return true;
+ }
+
+ ///
+ /// Rolls the chat back, so that the given answer becomes its last message.
+ ///
+ ///
+ /// What the rollback removes and what it keeps is documented at ChatThread.RollBackTo.
+ ///
+ /// The chat.
+ /// The content of the answer to keep as the last message.
+ /// True when later messages were removed.
+ public bool TryRollBack([NotNullWhen(true)] ChatThread? chat, IContent answer) => this.AreAllowed(chat) && chat.RollBackTo(answer);
+
+ ///
+ /// Makes the last question the draft again, and removes the answer to it.
+ ///
+ /// The chat.
+ /// The content of the question to edit.
+ /// True when the question became the draft.
+ public bool TryEditLastUserBlock([NotNullWhen(true)] ChatThread? chat, IContent question)
+ {
+ if (!this.AreAllowed(chat) || question is not ContentText || !Holds(chat, question))
+ return false;
+
+ var lastContent = chat.Blocks.Last().Content;
+ chat.RemoveForEditing(question);
+ if (lastContent is not null)
+ chat.Remove(lastContent);
+
+ return true;
+ }
+
+ ///
+ /// Makes a question without an answer the draft again.
+ ///
+ /// The chat.
+ /// The content of the question to edit.
+ /// True when the question became the draft.
+ public bool TryEditLastBlock([NotNullWhen(true)] ChatThread? chat, IContent question)
+ {
+ if (!this.AreAllowed(chat) || question is not ContentText || !Holds(chat, question))
+ return false;
+
+ chat.RemoveForEditing(question);
+ return true;
+ }
+
+ private static bool Holds(ChatThread chat, IContent content) => chat.Blocks.Any(block => block.Content == content);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
index e1dc635f..53f45602 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
@@ -55,31 +55,31 @@
@if (this.IsSecondToLastBlock && this.Role is ChatRole.USER && this.EditLastUserBlockFunc is not null)
{
-
+
}
@if (this.IsLastContentBlock && this.Role is ChatRole.USER && this.EditLastBlockFunc is not null)
{
-
+
}
@if (this.IsLastContentBlock && this.Role is ChatRole.AI && this.RegenerateFunc is not null)
{
-
+
}
@if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null)
{
-
+
}
@if (this.RemoveBlockFunc is not null)
{
-
+
}
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
index 86242dfd..5532d719 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -89,8 +89,15 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func RegenerateEnabled { get; set; } = () => false;
+ ///
+ /// Whether the user may change the messages of the chat right now, i.e., remove, edit, regenerate, or roll back.
+ ///
+ ///
+ /// The chat answers false while an answer is being written for it, since every change to a message
+ /// would collide with the answer. Whoever passes none of these actions needs no answer here.
+ ///
[Parameter]
- public Func RollbackEnabled { get; set; } = () => false;
+ public Func BlockActionsEnabled { get; set; } = () => false;
///
/// What the export offers, used both as the label of the export button and as the title of
@@ -321,6 +328,7 @@ public partial class ContentBlockComponent : MSGComponentBase
hash.Add(this.HideContent);
hash.Add(this.SettingsManager.IsDarkMode);
hash.Add(this.RegenerateEnabled());
+ hash.Add(this.BlockActionsEnabled());
hash.Add(this.Content.InitialRemoteWait);
hash.Add(this.Content.IsStreaming);
hash.Add(this.Content.FileAttachments.Count);
@@ -733,7 +741,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RemoveBlock()
{
- if (this.RemoveBlockFunc is null)
+ if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
return;
var remove = await this.DialogService.ShowMessageBox(
@@ -793,7 +801,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RegenerateBlock()
{
- if (this.RegenerateFunc is null)
+ if (this.RegenerateFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.AI)
@@ -811,7 +819,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task RollbackBlock()
{
- if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.RollbackEnabled())
+ if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.BlockActionsEnabled())
return;
var rollback = await this.DialogService.ShowMessageBox(
@@ -826,7 +834,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastBlock()
{
- if (this.EditLastBlockFunc is null)
+ if (this.EditLastBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)
@@ -837,7 +845,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private async Task EditLastUserBlock()
{
- if (this.EditLastUserBlockFunc is null)
+ if (this.EditLastUserBlockFunc is null || !this.BlockActionsEnabled())
return;
if(this.Role is not ChatRole.USER)
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor
index 6d98505a..5f41910b 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor
@@ -28,7 +28,7 @@
RegenerateFunc="@this.RegenerateBlock"
RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))"
RollbackFunc="@this.RollbackBlock"
- RollbackEnabled="@(() => !this.IsCurrentChatStreaming)"
+ BlockActionsEnabled="@(() => this.blockActions.AreAllowed(this.ChatThread))"
EditLastBlockFunc="@this.EditLastBlock"
EditLastUserBlockFunc="@this.EditLastUserBlock"/>
}
@@ -88,7 +88,7 @@
}
-
+
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
@@ -109,7 +109,7 @@
}
-
+
@@ -131,7 +131,7 @@
-
+
@if (this.SettingsManager.AreToolsEnabled())
{
@@ -140,7 +140,7 @@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
-
+
}
@if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence)
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index 1f4941f1..eaf2cb12 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -39,6 +39,17 @@ public partial class ChatComponent : MSGComponentBase
[Parameter]
public EventCallback ProviderChanged { get; set; }
+
+ ///
+ /// Tells whether an answer is being written for the open chat, whenever that changes.
+ ///
+ ///
+ /// For the parts of the chat page outside this component, such as the provider selection. The AI
+ /// job reports every part of an answer, and this component renders after each report. The page
+ /// learns only when an answer starts or ends, see OnAfterRenderAsync.
+ ///
+ [Parameter]
+ public EventCallback IsAnswerBeingWrittenChanged { get; set; }
[Parameter]
public Action WorkspaceName { get; set; } = _ => { };
@@ -91,6 +102,7 @@ public partial class ChatComponent : MSGComponentBase
private bool autoSaveEnabled;
private HashSet selectedToolIds = [];
private bool previousInputForbidden = true;
+ private bool announcedAnswerBeingWritten;
private Guid lastSeenChatId = Guid.Empty;
private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE;
private string currentWorkspaceName = string.Empty;
@@ -154,6 +166,11 @@ public partial class ChatComponent : MSGComponentBase
///
private ChatDraftAutosave? draftAutosave;
+ ///
+ /// The only way to change the messages of the open chat, which is locked while an answer is being written.
+ ///
+ private ChatBlockActions blockActions = null!;
+
///
/// A new chat which has not reached the disk yet, or null.
///
@@ -293,6 +310,9 @@ public partial class ChatComponent : MSGComponentBase
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
+ // Created before anything is awaited, because the first render may happen there already:
+ this.blockActions = new(chatId => this.AIJobService.IsChatGenerationActive(chatId));
+
//
// Leaving the chat page takes the composer along. Registered before anything is awaited, so
// that a component which goes away while it starts can never leave its handler behind:
@@ -568,6 +588,20 @@ public partial class ChatComponent : MSGComponentBase
this.previousInputForbidden = inputForbidden;
+ //
+ // The page hears about an answer starting or ending here rather than with every report of the
+ // AI job, since a render of the page redraws the workspace tree. This component renders after
+ // every such report and after another chat was opened, so the page never misses a change. The
+ // first render reports in any case: the page may still hold what another instance of this
+ // component told it, e.g., before the workspaces were shown or hidden.
+ //
+ var isAnswerBeingWritten = this.IsCurrentChatStreaming;
+ if (firstRender || isAnswerBeingWritten != this.announcedAnswerBeingWritten)
+ {
+ this.announcedAnswerBeingWritten = isAnswerBeingWritten;
+ await this.IsAnswerBeingWrittenChanged.InvokeAsync(isAnswerBeingWritten);
+ }
+
//
// Everything which can move the token count also renders this component: the selections in
// the toolbar, the attachments and the composer all travel through an event callback whose
@@ -755,6 +789,35 @@ public partial class ChatComponent : MSGComponentBase
private bool CanThreadBeCopied => this.CanThreadBeSaved && !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
+ ///
+ /// Whether a chat template can be made from the open chat. While an answer is being written, the template would copy it unfinished.
+ ///
+ private bool CanThreadBecomeTemplate => this.CanThreadBeSaved && !this.IsCurrentChatStreaming;
+
+ ///
+ /// Whether the user may pick another profile, which the open chat carries from then on.
+ ///
+ ///
+ /// Picking a profile replaces the thread with a copy which carries it. While an answer is being
+ /// written, the next update of the AI job puts the thread of the job back in place of that copy,
+ /// and the choice is lost.
+ ///
+ private bool CanProfileBeChanged => this.currentChatTemplate.AllowProfileUsage && !this.IsCurrentChatStreaming;
+
+ ///
+ /// Whether the user may change the attachments of the message being written.
+ ///
+ ///
+ /// Not while an answer is being written, just like the text of the message, and not while the
+ /// audio or video files of the chat are being transcribed.
+ ///
+ private bool CanAttachFiles => !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
+
+ // The chat template locks the profile for good, an answer being written only until it is complete:
+ private string ProfileDisabledText => this.currentChatTemplate.AllowProfileUsage
+ ? T("Profile changes are locked while a response is running.")
+ : T("Profile usage is disabled according to your chat template settings.");
+
///
/// Whether the open chat can move into a workspace, which is exactly when the workspace tree offers it.
///
@@ -916,6 +979,9 @@ public partial class ChatComponent : MSGComponentBase
private async Task ProfileWasChanged(Profile profile)
{
+ if (!this.CanProfileBeChanged)
+ return;
+
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
//
@@ -1014,6 +1080,22 @@ public partial class ChatComponent : MSGComponentBase
return this.earlyDataSourceOptions;
}
+
+ ///
+ /// Takes over what the user changed in the data source selection.
+ ///
+ ///
+ /// While an answer is being written, the selection is read-only and turns every change down
+ /// itself, since the answer reads the very options it shows. Checked here once more, so that the
+ /// chat never stores a change which reached it anyway.
+ ///
+ private async Task DataSourceOptionsWereChanged(DataSourceOptions updatedOptions)
+ {
+ if (this.IsCurrentChatStreaming)
+ return;
+
+ await this.SetCurrentDataSourceOptions(updatedOptions);
+ }
private async Task SetCurrentDataSourceOptions(DataSourceOptions updatedOptions)
{
@@ -1152,7 +1234,8 @@ public partial class ChatComponent : MSGComponentBase
/// message lives in the composer only. It becomes a disappearing chat of its own then. Starting a
/// new chat in a workspace creates the thread right away instead, see unstoredChat. That one is
/// stored with whatever its draft holds, and also while a transcript is still being made for it,
- /// since the transcript would otherwise arrive in a chat which is nowhere.
+ /// since the transcript would otherwise arrive in a chat which is nowhere. Only when its workspace
+ /// was deleted meanwhile, the chat goes along with it.
///
/// The composer is cleared before the chat is stored, since the message has moved into its chat.
/// Leaving twice in a row, e.g., with two quick clicks, therefore stores it only once.
@@ -1179,7 +1262,13 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.Clear();
}
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
+ {
+ // Deleting the workspace took the chat along. Storing it would bring the workspace back, as an unnamed one:
+ if (!WorkspaceBehaviour.IsWorkspaceExisting(unstoredChatBeingLeft.WorkspaceId))
+ return;
+
chat = unstoredChatBeingLeft;
+ }
else
return;
@@ -1261,6 +1350,14 @@ public partial class ChatComponent : MSGComponentBase
private async Task SendMessage(bool reuseLastUserPrompt = false)
{
+ //
+ // One answer at a time per chat. Checked before anything touches the chat or the composer:
+ // the AI job service would turn a second answer down only after the chat got its message and
+ // an empty answer, which then stays.
+ //
+ if (this.IsCurrentChatStreaming)
+ return;
+
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return;
@@ -1794,10 +1891,9 @@ public partial class ChatComponent : MSGComponentBase
private async Task RemoveBlock(IContent block)
{
- if(this.ChatThread is null)
+ if (!this.blockActions.TryRemove(this.ChatThread, block))
return;
-
- this.ChatThread.Remove(block);
+
this.hasUnsavedChanges = true;
await this.SaveThread();
this.StateHasChanged();
@@ -1805,13 +1901,12 @@ public partial class ChatComponent : MSGComponentBase
private async Task RegenerateBlock(IContent aiBlock)
{
- if(this.ChatThread is null)
- return;
-
if(!this.ChatThread.IsLLMProviderAllowed(this.Provider))
return;
-
- this.ChatThread.Remove(aiBlock, removeForRegenerate: true);
+
+ if (!this.blockActions.TryRemoveForRegenerate(this.ChatThread, aiBlock))
+ return;
+
this.hasUnsavedChanges = true;
this.StateHasChanged();
@@ -1820,11 +1915,8 @@ public partial class ChatComponent : MSGComponentBase
private async Task RollbackBlock(IContent aiBlock)
{
- if (this.ChatThread is null || this.IsCurrentChatStreaming)
- return;
-
// Which parts of the thread a rollback resets and which it keeps is documented at RollBackTo:
- if (!this.ChatThread.RollBackTo(aiBlock))
+ if (!this.blockActions.TryRollBack(this.ChatThread, aiBlock))
return;
// The rollback reset the AI-selected data sources, which the data source selection still shows:
@@ -1838,19 +1930,9 @@ public partial class ChatComponent : MSGComponentBase
private Task EditLastUserBlock(IContent block)
{
- if(this.ChatThread is null)
+ if (!this.blockActions.TryEditLastUserBlock(this.ChatThread, block))
return Task.CompletedTask;
- if (block is not ContentText)
- return Task.CompletedTask;
-
- var lastBlock = this.ChatThread.Blocks.Last();
- var lastBlockContent = lastBlock.Content;
- if(lastBlockContent is null)
- return Task.CompletedTask;
-
- this.ChatThread.RemoveForEditing(block);
- this.ChatThread.Remove(lastBlockContent);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
@@ -1859,13 +1941,9 @@ public partial class ChatComponent : MSGComponentBase
private Task EditLastBlock(IContent block)
{
- if(this.ChatThread is null)
+ if (!this.blockActions.TryEditLastBlock(this.ChatThread, block))
return Task.CompletedTask;
- if (block is not ContentText)
- return Task.CompletedTask;
-
- this.ChatThread.RemoveForEditing(block);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
diff --git a/app/MindWork AI Studio/Components/ChatTemplateSelection.razor.cs b/app/MindWork AI Studio/Components/ChatTemplateSelection.razor.cs
index 25bbdd1c..4e18c010 100644
--- a/app/MindWork AI Studio/Components/ChatTemplateSelection.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatTemplateSelection.razor.cs
@@ -64,6 +64,9 @@ public partial class ChatTemplateSelection : MSGComponentBase
private async Task CreateNewChatTemplateFromChat()
{
+ if (!this.CanChatThreadBeUsedForTemplate)
+ return;
+
var dialogParameters = new DialogParameters
{
{ x => x.CreateTemplateFromExistingChatThread, true },
diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor b/app/MindWork AI Studio/Components/DataSourceSelection.razor
index 58d97561..82f4090c 100644
--- a/app/MindWork AI Studio/Components/DataSourceSelection.razor
+++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor
@@ -57,7 +57,14 @@
}
else if (this.showDataSourceSelection)
{
-
+ @if (this.ReadOnly && !string.IsNullOrWhiteSpace(this.ReadOnlyMessage))
+ {
+
+ @this.ReadOnlyMessage
+
+ }
+
+
@if (this.areDataSourcesEnabled)
{
@* Where Semantic Search cannot be used, there is no choice to make, only the reason to tell: *@
@@ -69,15 +76,15 @@
}
else
{
-
+
}
-
+
@* With Semantic Search, no agent takes part: nothing would validate the retrieved data. *@
@if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation && !this.IsSemanticSearchEffective)
{
-
+
}
@switch (this.aiBasedSourceSelection)
@@ -95,8 +102,8 @@
break;
case false:
-
-
+
+
@foreach (var source in this.GetListedDataSources())
{
diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs
index ef6178fa..971f10fa 100644
--- a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs
+++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs
@@ -45,11 +45,21 @@ public partial class DataSourceSelection : MSGComponentBase
///
///
/// For options somebody else decided on, such as those of a chat template an organization
- /// rolled out. Seeing which data such a chat will search is the point; changing it here is not.
+ /// rolled out. Seeing which data such a chat will search is the point; changing it here is not.
+ ///
+ /// Also for options which must not change right now, such as those of a chat while an answer is
+ /// being written for it: the answer reads them, and they are the very options shown here, so a
+ /// change would reach it halfway. Every change is turned down before it touches the options.
///
[Parameter]
public bool ReadOnly { get; set; }
+ ///
+ /// Tells why the options cannot be changed, shown at the top of the popover while they are read-only.
+ ///
+ [Parameter]
+ public string ReadOnlyMessage { get; set; } = string.Empty;
+
///
/// Whether the options edited here are the data source defaults of the chat.
///
@@ -324,6 +334,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task EnabledChanged(bool state)
{
+ if (this.ReadOnly)
+ return;
+
this.areDataSourcesEnabled = state;
this.DataSourceOptions.DisableDataSources = !this.areDataSourcesEnabled;
@@ -334,6 +347,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task AutoModeChanged(bool state)
{
+ if (this.ReadOnly)
+ return;
+
this.aiBasedSourceSelection = state;
this.DataSourceOptions.AutomaticDataSourceSelection = this.aiBasedSourceSelection;
@@ -343,6 +359,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task SemanticSearchChanged(bool state)
{
+ if (this.ReadOnly)
+ return;
+
this.retrievalMode = state ? DataSourceRetrievalMode.SEMANTIC_SEARCH : DataSourceRetrievalMode.EVERY_MESSAGE;
this.DataSourceOptions.RetrievalMode = this.retrievalMode;
@@ -384,6 +403,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task ValidationModeChanged(bool state)
{
+ if (this.ReadOnly)
+ return;
+
this.aiBasedValidation = state;
this.DataSourceOptions.AutomaticValidation = this.aiBasedValidation;
@@ -393,6 +415,9 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task SelectionChanged(IReadOnlyCollection? chosenDataSources)
{
+ if (this.ReadOnly)
+ return;
+
this.selectedDataSources = chosenDataSources ?? [];
//
diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs
index 8f107f4d..29980226 100644
--- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs
+++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs
@@ -34,6 +34,10 @@ public partial class ProviderSelection : MSGComponentBase
///
/// Gets or sets whether provider selection is disabled.
///
+ ///
+ /// A choice which reaches a disabled selection anyway is turned down. Keeping the selected provider
+ /// up to date after its settings changed is no choice, so that goes on.
+ ///
[Parameter]
public bool Disabled { get; set; }
@@ -55,6 +59,9 @@ public partial class ProviderSelection : MSGComponentBase
private async Task SelectionChanged(AIStudio.Settings.Provider provider)
{
+ if (this.Disabled)
+ return;
+
this.ProviderSettings = provider;
await this.ProviderSettingsChanged.InvokeAsync(provider);
}
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 9112a60f..7099ef65 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -382,11 +382,15 @@ public partial class Workspaces : MSGComponentBase
private bool IsChatTreeItemBusy(TreeItemData treeItem)
{
- return treeItem.Type is TreeItemType.CHAT
- && (this.AIJobService.IsChatGenerationActive(treeItem.ChatId)
- || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(treeItem.ChatId)));
+ return treeItem.Type is TreeItemType.CHAT && this.IsChatBusy(treeItem.ChatId);
}
+ ///
+ /// Whether an answer is being written for the given chat, or a transcript is being made for it.
+ ///
+ /// The chat to check.
+ private bool IsChatBusy(Guid chatId) => this.AIJobService.IsChatGenerationActive(chatId) || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(chatId));
+
private string GetChatTreeItemTextStyle(TreeItemData treeItem)
{
var status = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(treeItem.ChatId))?.Status;
@@ -922,6 +926,18 @@ public partial class Workspaces : MSGComponentBase
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
+ //
+ // Deleting a workspace while one of its chats is being worked on would pull the ground from
+ // under that work. The button cannot tell, since the chats of a workspace may not be loaded
+ // yet, so we check before asking: nobody should confirm something that cannot happen anyway.
+ //
+ var chatIds = this.ReadChatIdsOfWorkspace(workspaceId);
+ if (chatIds.Any(this.IsChatBusy))
+ {
+ await this.WarnAboutBusyWorkspaceAsync(workspaceName);
+ return;
+ }
+
var chatCount = Directory.EnumerateDirectories(workspacePath).Count();
var dialogParameters = new DialogParameters
{
@@ -933,10 +949,43 @@ public partial class Workspaces : MSGComponentBase
if (dialogResult is null || dialogResult.Canceled)
return;
- Directory.Delete(workspacePath, true);
- await WorkspaceBehaviour.RemoveWorkspaceFromCacheAsync(workspaceId);
+ if (!await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, this.IsChatBusy))
+ {
+ await this.WarnAboutBusyWorkspaceAsync(workspaceName);
+ return;
+ }
+
+ foreach (var chatId in chatIds)
+ this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForChat(chatId));
+
await this.LoadTreeItemsAsync(startPrefetch: false);
+
+ // The open chat went along with its workspace. Left on the screen, it would be stored again sooner or later:
+ if (this.CurrentChatThread?.WorkspaceId == workspaceId)
+ {
+ this.CurrentChatThread = null;
+ await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
+ }
}
+
+ ///
+ /// Reads the chats of the given workspace, together with the open chat when it belongs there.
+ ///
+ ///
+ /// A new chat started in a workspace reaches the disk only with its first message. Until then, it
+ /// is missing from the workspace on the disk, but it may already wait for a transcript.
+ ///
+ /// The workspace to read.
+ private HashSet ReadChatIdsOfWorkspace(Guid workspaceId)
+ {
+ var chatIds = WorkspaceBehaviour.ReadChatIdsOfWorkspace(workspaceId).ToHashSet();
+ if (this.CurrentChatThread is { } openChat && openChat.WorkspaceId == workspaceId)
+ chatIds.Add(openChat.ChatId);
+
+ return chatIds;
+ }
+
+ private Task WarnAboutBusyWorkspaceAsync(string workspaceName) => MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FolderDelete, string.Format(T("The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."), workspaceName)));
///
/// Moves the given chat into another workspace and shows it there in the tree.
diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor
index 802a9b62..b9db7708 100644
--- a/app/MindWork AI Studio/Pages/Chat.razor
+++ b/app/MindWork AI Studio/Pages/Chat.razor
@@ -32,7 +32,7 @@
-
+
@if (this.AreWorkspacesVisible)
{
@@ -97,6 +97,7 @@
@@ -124,6 +125,7 @@
@@ -135,6 +137,7 @@
diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs
index 41139a41..9c13a418 100644
--- a/app/MindWork AI Studio/Pages/Chat.razor.cs
+++ b/app/MindWork AI Studio/Pages/Chat.razor.cs
@@ -22,6 +22,17 @@ public partial class Chat : MSGComponentBase
private ChatThread? chatThread;
private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE;
+
+ ///
+ /// Whether an answer is being written for the open chat, as the chat component last told.
+ ///
+ ///
+ /// The provider selection is locked meanwhile. Another provider would not reach the answer, which
+ /// keeps the one it started with, but the chat would show the other one while the answer is still
+ /// being written.
+ ///
+ private bool isAnswerBeingWritten;
+
private bool workspaceOverlayVisible;
private bool workspaceSearchVisible;
private string currentWorkspaceName = string.Empty;
diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
index a6c7ebf7..583390ca 100644
--- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
@@ -3666,6 +3666,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "zuzüglic
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Wähle zuerst einen Anbieter aus"
+-- Profile changes are locked while a response is running.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profiländerungen sind gesperrt, solange eine Antwort generiert wird."
+
+-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Änderungen an den Datenquellen sind gesperrt, solange eine Antwort generiert wird. Ihre aktuelle Auswahl wird unten angezeigt und gilt ab der nächsten Nachricht wieder, sobald der Vorgang abgeschlossen ist."
+
-- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Neuen Chat im Arbeitsbereich '{0}' starten"
@@ -5526,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Arbeitsberei
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Möchten Sie diesen Chat kopieren? Die Kopie wird anschließend geöffnet, daher gehen alle ungespeicherten Änderungen des aktuell geöffneten Chats verloren."
+-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "Der Arbeitsbereich „{0}“ kann nicht gelöscht werden, solange in einem seiner Chats gerade eine Antwort generiert oder eine Transkription erstellt wird. Bitte versuchen Sie es erneut, sobald der Vorgang abgeschlossen ist."
+
-- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chatname"
diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
index f33ca92e..0c704375 100644
--- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
@@ -3666,6 +3666,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0}
-- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
+-- Profile changes are locked while a response is running.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3662116565"] = "Profile changes are locked while a response is running."
+
+-- Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3682097866"] = "Data source changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished."
+
-- Start new chat in workspace "{0}"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace \"{0}\""
@@ -5526,6 +5532,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspac
-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost."
+-- The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3765015122"] = "The workspace '{0}' cannot be deleted while one of its chats is being answered or transcribed. Please try again once that is finished."
+
-- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name"
diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
index 22c69fe5..03969a52 100644
--- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
+++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
@@ -660,7 +660,7 @@ public static class WorkspaceBehaviour
}
}
- public static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId)
+ private static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
@@ -816,6 +816,34 @@ public static class WorkspaceBehaviour
}
}
+ ///
+ /// Whether the given workspace exists on the disk.
+ ///
+ ///
+ /// The temporary chats belong to no workspace, so for them, the empty ID, this is always true.
+ ///
+ /// The workspace to look for.
+ public static bool IsWorkspaceExisting(Guid workspaceId) => workspaceId == Guid.Empty || Directory.Exists(Path.Join(WorkspaceRootDirectory, workspaceId.ToString()));
+
+ ///
+ /// Reads the IDs of the chats the given workspace holds on the disk.
+ ///
+ /// The workspace to read.
+ /// The IDs of its chats; none when the workspace does not exist.
+ public static IReadOnlyList ReadChatIdsOfWorkspace(Guid workspaceId)
+ {
+ var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
+ if (!Directory.Exists(workspacePath))
+ return [];
+
+ var chatIds = new List();
+ foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
+ if (Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
+ chatIds.Add(chatId);
+
+ return chatIds;
+ }
+
public static bool IsChatExisting(LoadChat loadChat)
{
var chatPath = loadChat.WorkspaceId == Guid.Empty
@@ -1398,6 +1426,38 @@ public static class WorkspaceBehaviour
return true;
}
+ ///
+ /// Deletes the given workspace together with all of its chats, unless one of them is busy.
+ ///
+ /// The workspace to delete.
+ /// Whether an answer or a transcript is being made for the chat with the given ID.
+ /// True when the workspace is gone, which includes it never having been there. False when a busy chat kept it.
+ ///
+ /// The work on a busy chat stores the chat once more when it is done. That would create the
+ /// directory of the chat anew and the one of the workspace with it, which then comes back as an
+ /// unnamed workspace. Deleting all chats but the busy one would be no better: the user asked for
+ /// the workspace to go, not for some of its chats. Hence, a busy chat keeps the whole workspace.
+ ///
+ /// Asking the user beforehand is up to the caller, and so is taking an open chat of the workspace
+ /// off the screen.
+ ///
+ public static async Task DeleteWorkspaceAsync(Guid workspaceId, Func isChatBusy)
+ {
+ var chatIds = ReadChatIdsOfWorkspace(workspaceId);
+ if (chatIds.Any(isChatBusy))
+ return false;
+
+ var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
+ if (Directory.Exists(workspacePath))
+ Directory.Delete(workspacePath, true);
+
+ await RemoveWorkspaceFromCacheAsync(workspaceId);
+ foreach (var chatId in chatIds)
+ ForgetChatSemaphore(workspaceId, chatId);
+
+ return true;
+ }
+
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
{
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
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 23a8b7a3..9ef354d2 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
@@ -45,9 +45,11 @@
- Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace.
- Fixed a chat you moved from the disappearing chats into a workspace still being listed among the disappearing chats.
- Fixed a new chat not appearing in the workspace list while AI Studio transcribed an audio or video file you had attached to it.
+- Fixed a deleted workspace coming back as "Unnamed workspace" when one of its chats was still open or being answered. AI Studio now deletes a workspace only when none of its chats is being answered or transcribed, and it closes the chat you had open in it.
- 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 AI Studio waiting for an answer you had removed while it was still being written, so you could not send another message. While an answer is being written, the messages, attachments, provider, profile, and data sources of the chat now stay as they are until you stop the answer or it is complete. Thanks, Bernhard, for reporting this issue.
- 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.
- Fixed the security notice about removed instructions (prompt injection) appearing only minutes later while AI Studio updated a data source in the background. The notice also mixed your own attachments with files of the data source. Now each notice appears as soon as AI Studio has read the content, for example, the attachments of your message, and it names only that content.
- Fixed the security notice about removed instructions (prompt injection) appearing several times for one answer when AI Studio checks which passages of your data sources fit your question: once for every passage it checked, and once more for all of them together. You now get one notice per answer, and a file counts once, however many of its passages were affected.
diff --git a/app/Tests/Chat/ChatBlockActionsTests.cs b/app/Tests/Chat/ChatBlockActionsTests.cs
new file mode 100644
index 00000000..31d2792c
--- /dev/null
+++ b/app/Tests/Chat/ChatBlockActionsTests.cs
@@ -0,0 +1,254 @@
+using AIStudio.Chat;
+
+namespace AIStudio.Tests.Chat;
+
+///
+/// Checks that the messages of a chat stay as they are while an answer is being written for it.
+///
+///
+/// While an answer is being written, the AI job holds the very same thread as the chat. A message the
+/// user removes then does not come back, but the chat waits for an answer nobody sees, and the job
+/// stores the conversation while it changes. So every change to a message is turned down until the
+/// answer is complete or stopped, and works as before once it is.
+///
+[TestFixture]
+public sealed class ChatBlockActionsTests
+{
+ private static readonly DateTimeOffset START = new(2026, 10, 13, 10, 0, 0, TimeSpan.Zero);
+
+ [Test]
+ public void RemovingTheAnswerBeingWrittenLeavesTheChatAsItIs()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var answer = Block(ChatRole.AI, "Answer being", 2);
+ var chat = Chat(question, answer);
+
+ var removed = WhileWriting(chat).TryRemove(chat, answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(removed, Is.False);
+ Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content, answer.Content }), "The answer stays, since the job goes on writing into it.");
+ });
+ }
+
+ [Test]
+ public void OlderMessagesStayWhileAnAnswerIsBeingWritten()
+ {
+ var firstQuestion = Block(ChatRole.USER, "First question", 1);
+ var firstAnswer = Block(ChatRole.AI, "First answer", 2);
+ var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
+ var blocks = chat.Blocks.ToList();
+ var actions = WhileWriting(chat);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(actions.TryRemove(chat, firstQuestion.Content!), Is.False);
+ Assert.That(actions.TryRemove(chat, firstAnswer.Content!), Is.False);
+ Assert.That(actions.TryRemoveForRegenerate(chat, firstAnswer.Content!), Is.False);
+ Assert.That(chat.Blocks, Is.EqualTo(blocks), "The job stores the conversation meanwhile, so no message may change, not even an older one.");
+ });
+ }
+
+ [Test]
+ public void EditingTheQuestionChangesNeitherTheChatNorTheDraft()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var answer = Block(ChatRole.AI, "Answer being", 2);
+ var chat = Chat(question, answer);
+ var actions = WhileWriting(chat);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(actions.TryEditLastUserBlock(chat, question.Content!), Is.False);
+ Assert.That(actions.TryEditLastBlock(chat, question.Content!), Is.False);
+ Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Both the question and the answer being written stay.");
+ Assert.That(chat.Draft.IsEmpty, Is.True, "The question does not become the draft, since it could not be sent anyway.");
+ });
+ }
+
+ [Test]
+ public void RegeneratingKeepsTheAnswerBeingWritten()
+ {
+ var answer = Block(ChatRole.AI, "Answer being", 2);
+ var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
+
+ var removed = WhileWriting(chat).TryRemoveForRegenerate(chat, answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(removed, Is.False, "Regenerating would leave a second answer next to the one being written.");
+ Assert.That(chat.Blocks, Has.Count.EqualTo(2));
+ });
+ }
+
+ [Test]
+ public void RollingBackIsTurnedDownWhileAnAnswerIsBeingWritten()
+ {
+ var firstAnswer = Block(ChatRole.AI, "First answer", 2);
+ var chat = Chat(Block(ChatRole.USER, "First question", 1), firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
+
+ var rolledBack = WhileWriting(chat).TryRollBack(chat, firstAnswer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rolledBack, Is.False);
+ Assert.That(chat.Blocks, Has.Count.EqualTo(4));
+ });
+ }
+
+ [Test]
+ public void RemovingAMessageWorksOnceTheAnswerIsComplete()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var answer = Block(ChatRole.AI, "Answer", 2);
+ var chat = Chat(question, answer);
+
+ var removed = Done().TryRemove(chat, answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(removed, Is.True);
+ Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }));
+ });
+ }
+
+ [Test]
+ public void RegeneratingWorksOnceTheAnswerIsComplete()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var answer = Block(ChatRole.AI, "Answer", 2);
+ var chat = Chat(question, answer);
+
+ var removed = Done().TryRemoveForRegenerate(chat, answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(removed, Is.True);
+ Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }), "The question stays, to be answered anew.");
+ });
+ }
+
+ [Test]
+ public void RollingBackWorksOnceTheAnswerIsComplete()
+ {
+ var firstQuestion = Block(ChatRole.USER, "First question", 1);
+ var firstAnswer = Block(ChatRole.AI, "First answer", 2);
+ var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer", 4));
+
+ var rolledBack = Done().TryRollBack(chat, firstAnswer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rolledBack, Is.True);
+ Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { firstQuestion.Content, firstAnswer.Content }));
+ });
+ }
+
+ [Test]
+ public void EditingTheQuestionWorksOnceTheAnswerIsComplete()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var chat = Chat(question, Block(ChatRole.AI, "Answer", 2));
+
+ var edited = Done().TryEditLastUserBlock(chat, question.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(edited, Is.True);
+ Assert.That(chat.Blocks, Is.Empty, "The question goes back into the composer, and the answer to it goes.");
+ Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
+ });
+ }
+
+ [Test]
+ public void EditingAQuestionWithoutAnswerWorksOnceTheAnswerIsGone()
+ {
+ var question = Block(ChatRole.USER, "Question", 1);
+ var chat = Chat(question);
+
+ var edited = Done().TryEditLastBlock(chat, question.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(edited, Is.True);
+ Assert.That(chat.Blocks, Is.Empty);
+ Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
+ });
+ }
+
+ [Test]
+ public void EditingAQuestionOfAnotherChatKeepsTheAnswer()
+ {
+ var answer = Block(ChatRole.AI, "Answer", 2);
+ var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
+
+ var edited = Done().TryEditLastUserBlock(chat, new ContentText { Text = "Question of another chat" });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(edited, Is.False);
+ Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Removing the last answer only makes sense together with the question to it.");
+ Assert.That(chat.Draft.IsEmpty, Is.True);
+ });
+ }
+
+ [Test]
+ public void AnAnswerBeingWrittenInAnotherChatLocksNothingHere()
+ {
+ var answer = Block(ChatRole.AI, "Answer", 2);
+ var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
+ var otherChatId = Guid.NewGuid();
+ var askedChatIds = new List();
+ var actions = new ChatBlockActions(chatId =>
+ {
+ askedChatIds.Add(chatId);
+ return chatId == otherChatId;
+ });
+
+ var removed = actions.TryRemove(chat, answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(removed, Is.True);
+ Assert.That(askedChatIds, Is.EqualTo(new[] { chat.ChatId }), "Only the chat being changed decides.");
+ });
+ }
+
+ [Test]
+ public void WithoutAChatNothingIsAllowed()
+ {
+ var actions = Done();
+ var content = new ContentText { Text = "Question" };
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(actions.AreAllowed(null), Is.False);
+ Assert.That(actions.TryRemove(null, content), Is.False);
+ Assert.That(actions.TryRemoveForRegenerate(null, content), Is.False);
+ Assert.That(actions.TryRollBack(null, content), Is.False);
+ Assert.That(actions.TryEditLastUserBlock(null, content), Is.False);
+ Assert.That(actions.TryEditLastBlock(null, content), Is.False);
+ });
+ }
+
+ ///
+ /// The actions while an answer is being written for the given chat.
+ ///
+ private static ChatBlockActions WhileWriting(ChatThread chat) => new(chatId => chatId == chat.ChatId);
+
+ ///
+ /// The actions while no answer is being written for any chat.
+ ///
+ private static ChatBlockActions Done() => new(_ => false);
+
+ private static ChatThread Chat(params ContentBlock[] blocks) => new() { ChatId = Guid.NewGuid(), Blocks = [..blocks] };
+
+ private static ContentBlock Block(ChatRole role, string text, int minute) => new()
+ {
+ Time = START.AddMinutes(minute),
+ ContentType = ContentType.TEXT,
+ Content = new ContentText { Text = text },
+ Role = role,
+ };
+}
\ No newline at end of file
diff --git a/app/Tests/Tools/WorkspaceDeleteTests.cs b/app/Tests/Tools/WorkspaceDeleteTests.cs
new file mode 100644
index 00000000..b3dc68ce
--- /dev/null
+++ b/app/Tests/Tools/WorkspaceDeleteTests.cs
@@ -0,0 +1,143 @@
+using AIStudio.Chat;
+using AIStudio.Settings;
+using AIStudio.Tools;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Checks when a workspace may be deleted together with its chats.
+///
+///
+/// The work on a busy chat, an answer or a transcript, stores the chat once more when it is done. Had
+/// its workspace been deleted meanwhile, that brings the workspace back as an unnamed one. A busy chat
+/// therefore keeps the whole workspace, while the chats of other workspaces have no say in it.
+///
+[TestFixture]
+[NonParallelizable]
+public sealed class WorkspaceDeleteTests
+{
+ private string? previousDataDirectory;
+ private string testDirectory = string.Empty;
+
+ [SetUp]
+ public async Task PrepareDataDirectory()
+ {
+ //
+ // The data directory is static state of the whole application, which is why this fixture
+ // does not run alongside others. The workspace behaviour keeps the roots of the workspaces
+ // and of the temporary chats fixed, as the app never changes its data directory, so it gets
+ // told about the one of this test separately. The cache is static as well, so it starts
+ // over from what the new data directory holds: nothing.
+ //
+ this.previousDataDirectory = SettingsManager.DataDirectory;
+ this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-workspace-delete-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(this.testDirectory);
+ SettingsManager.DataDirectory = this.testDirectory;
+ WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
+
+ await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
+ }
+
+ [TearDown]
+ public void RemoveDataDirectory()
+ {
+ SettingsManager.DataDirectory = this.previousDataDirectory;
+ WorkspaceBehaviour.TestDataDirectory = null;
+
+ // Whoever reads the cache next must not get the workspaces of this test:
+ WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
+
+ try
+ {
+ Directory.Delete(this.testDirectory, true);
+ }
+ catch (IOException)
+ {
+ // A temporary directory we could not remove says nothing about the code under test.
+ }
+ }
+
+ [Test]
+ public async Task ABusyChatKeepsTheWholeWorkspace()
+ {
+ var workspaceId = await CreateWorkspaceAsync("Projects");
+ var busyChat = await StoreChatAsync(workspaceId, "Meeting notes");
+ var idleChat = await StoreChatAsync(workspaceId, "Old notes");
+
+ var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
+
+ var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
+ Assert.Multiple(() =>
+ {
+ Assert.That(deleted, Is.False, "The caller has to learn that the workspace is still there.");
+ Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, busyChat.ChatId)), Is.True, "The answer or the transcript still needs the chat it is made for.");
+ Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, idleChat.ChatId)), Is.True, "The user asked for the whole workspace to go, not for some of its chats.");
+ Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Contain(workspaceId), "The workspace list keeps showing the workspace, which still exists.");
+ });
+ }
+
+ [Test]
+ public async Task AWorkspaceWithoutBusyChatsIsGone()
+ {
+ var workspaceId = await CreateWorkspaceAsync("Projects");
+ var chat = await StoreChatAsync(workspaceId, "Meeting notes");
+
+ var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, _ => false);
+
+ var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
+ Assert.Multiple(() =>
+ {
+ Assert.That(deleted, Is.True);
+ Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False, "The workspace has to be gone from the disk, its chats included.");
+ Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, chat.ChatId)), Is.False);
+ Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Not.Contain(workspaceId), "The workspace list must not show a workspace which is gone.");
+ });
+ }
+
+ [Test]
+ public async Task ABusyChatInAnotherWorkspaceDoesNotKeepTheWorkspace()
+ {
+ var workspaceId = await CreateWorkspaceAsync("Projects");
+ await StoreChatAsync(workspaceId, "Meeting notes");
+ var otherId = await CreateWorkspaceAsync("Archive");
+ var busyChat = await StoreChatAsync(otherId, "Old notes");
+
+ var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(deleted, Is.True, "Only the chats of the workspace being deleted count.");
+ Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False);
+ Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(otherId, busyChat.ChatId)), Is.True, "The other workspace has nothing to do with it and stays as it is.");
+ });
+ }
+
+ [Test]
+ public async Task AWorkspaceWhichIsNotThereCountsAsDeleted()
+ {
+ var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(Guid.NewGuid(), _ => false);
+
+ Assert.That(deleted, Is.True, "There is nothing left to delete, so the caller may go on.");
+ }
+
+ [Test]
+ public void TheDisappearingChatsAlwaysExist()
+ {
+ // The temporary chats belong to no workspace, so a new chat among them asks with the empty ID, and it has to be stored:
+ Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(Guid.Empty), Is.True);
+ }
+
+ private static async Task CreateWorkspaceAsync(string name)
+ {
+ var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(name);
+ Assert.That(result.Success, Is.True, $"The workspace '{name}' could not be created.");
+ return result.Workspace.WorkspaceId;
+ }
+
+ private static async Task StoreChatAsync(Guid workspaceId, string name)
+ {
+ var chat = new ChatThread { WorkspaceId = workspaceId, ChatId = Guid.NewGuid(), Name = name };
+ Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, $"The chat '{name}' could not be stored.");
+ return chat;
+ }
+}
\ No newline at end of file