Locked changes to a chat while an answer is being written (#1058)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-13 18:50:56 +02:00
1 parent 88d1f4d96a
commit fbb65fd72d
19 files changed
+841 -59

No files matched your search

@@ -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 @@
</MudTooltip>
}
<ChatTemplateSelection MarginLeft="" CanChatThreadBeUsedForTemplate="@this.CanThreadBeSaved" CurrentChatThread="@this.ChatThread" CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/>
<ChatTemplateSelection MarginLeft="" CanChatThreadBeUsedForTemplate="@this.CanThreadBecomeTemplate" CurrentChatThread="@this.ChatThread" CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/>
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
@@ -109,7 +109,7 @@
</MudTooltip>
}
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/>
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@(!this.CanAttachFiles)"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
@@ -131,7 +131,7 @@
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.currentChatTemplate.AllowProfileUsage)" DisabledText="@T("Profile usage is disabled according to your chat template settings.")"/>
<ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.CanProfileBeChanged)" DisabledText="@this.ProfileDisabledText"/>
@if (this.SettingsManager.AreToolsEnabled())
{
@@ -140,7 +140,7 @@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@(async options => await this.SetCurrentDataSourceOptions(options))" DataSourcesAISelected="@this.GetAgentSelectedDataSources()"/>
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@this.DataSourceOptionsWereChanged" DataSourcesAISelected="@this.GetAgentSelectedDataSources()" ReadOnly="@this.IsCurrentChatStreaming" ReadOnlyMessage="@T("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.")"/>
}
@if (this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence)
@@ -39,6 +39,17 @@ public partial class ChatComponent : MSGComponentBase
[Parameter]
public EventCallback<AIStudio.Settings.Provider> ProviderChanged { get; set; }
/// <summary>
/// Tells whether an answer is being written for the open chat, whenever that changes.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Parameter]
public EventCallback<bool> IsAnswerBeingWrittenChanged { get; set; }
[Parameter]
public Action<string> WorkspaceName { get; set; } = _ => { };
@@ -91,6 +102,7 @@ public partial class ChatComponent : MSGComponentBase
private bool autoSaveEnabled;
private HashSet<string> 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
/// </summary>
private ChatDraftAutosave? draftAutosave;
/// <summary>
/// The only way to change the messages of the open chat, which is locked while an answer is being written.
/// </summary>
private ChatBlockActions blockActions = null!;
/// <summary>
/// A new chat which has not reached the disk yet, or null.
/// </summary>
@@ -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);
/// <summary>
/// Whether a chat template can be made from the open chat. While an answer is being written, the template would copy it unfinished.
/// </summary>
private bool CanThreadBecomeTemplate => this.CanThreadBeSaved && !this.IsCurrentChatStreaming;
/// <summary>
/// Whether the user may pick another profile, which the open chat carries from then on.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private bool CanProfileBeChanged => this.currentChatTemplate.AllowProfileUsage && !this.IsCurrentChatStreaming;
/// <summary>
/// Whether the user may change the attachments of the message being written.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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.");
/// <summary>
/// Whether the open chat can move into a workspace, which is exactly when the workspace tree offers it.
/// </summary>
@@ -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;
}
/// <summary>
/// Takes over what the user changed in the data source selection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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.<br/><br/>
/// 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.<br/><br/>
///
/// 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();
@@ -64,6 +64,9 @@ public partial class ChatTemplateSelection : MSGComponentBase
private async Task CreateNewChatTemplateFromChat()
{
if (!this.CanChatThreadBeUsedForTemplate)
return;
var dialogParameters = new DialogParameters<SettingsDialogChatTemplate>
{
{ x => x.CreateTemplateFromExistingChatThread, true },
@@ -57,7 +57,14 @@
}
else if (this.showDataSourceSelection)
{
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true"/>
@if (this.ReadOnly && !string.IsNullOrWhiteSpace(this.ReadOnlyMessage))
{
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-2">
@this.ReadOnlyMessage
</MudAlert>
}
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true" Disabled="@this.ReadOnly"/>
@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
{
<MudTextSwitch Label="@T("Semantic Search")" Value="@this.IsSemanticSearchPreferred" LabelOn="@T("Yes, the AI searches your data sources itself, whenever a question calls for it (Semantic Search).")" LabelOff="@T("No, AI Studio searches your data sources with every message, before the AI answers (classic RAG).")" ValueChanged="@this.SemanticSearchChanged" Dense="@true"/>
<MudTextSwitch Label="@T("Semantic Search")" Value="@this.IsSemanticSearchPreferred" LabelOn="@T("Yes, the AI searches your data sources itself, whenever a question calls for it (Semantic Search).")" LabelOff="@T("No, AI Studio searches your data sources with every message, before the AI answers (classic RAG).")" ValueChanged="@this.SemanticSearchChanged" Dense="@true" Disabled="@this.ReadOnly"/>
}
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true"/>
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true" Disabled="@this.ReadOnly"/>
@* With Semantic Search, no agent takes part: nothing would validate the retrieved data. *@
@if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation && !this.IsSemanticSearchEffective)
{
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true"/>
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true" Disabled="@this.ReadOnly"/>
}
@switch (this.aiBasedSourceSelection)
@@ -95,8 +102,8 @@
break;
case false:
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@this.aiBasedSourceSelection">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" Style="max-height: 14em;">
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@(this.aiBasedSourceSelection || this.ReadOnly)">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" ReadOnly="@this.ReadOnly" Style="max-height: 14em;">
@foreach (var source in this.GetListedDataSources())
{
<DataSourceSelectionRow DataSource="@source" BlockReason="@this.GetBlockReason(source)"/>
@@ -45,11 +45,21 @@ public partial class DataSourceSelection : MSGComponentBase
/// </summary>
/// <remarks>
/// 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.<br/><br/>
///
/// 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.
/// </remarks>
[Parameter]
public bool ReadOnly { get; set; }
/// <summary>
/// Tells why the options cannot be changed, shown at the top of the popover while they are read-only.
/// </summary>
[Parameter]
public string ReadOnlyMessage { get; set; } = string.Empty;
/// <summary>
/// Whether the options edited here are the data source defaults of the chat.
/// </summary>
@@ -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<IDataSource>? chosenDataSources)
{
if (this.ReadOnly)
return;
this.selectedDataSources = chosenDataSources ?? [];
//
@@ -34,6 +34,10 @@ public partial class ProviderSelection : MSGComponentBase
/// <summary>
/// Gets or sets whether provider selection is disabled.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
@@ -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);
}
/// <summary>
/// Whether an answer is being written for the given chat, or a transcript is being made for it.
/// </summary>
/// <param name="chatId">The chat to check.</param>
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<ConfirmDialog>
{
@@ -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);
}
}
/// <summary>
/// Reads the chats of the given workspace, together with the open chat when it belongs there.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="workspaceId">The workspace to read.</param>
private HashSet<Guid> 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)));
/// <summary>
/// Moves the given chat into another workspace and shows it there in the tree.