Fixed chats being stored when chats are stored manually or workspaces are disabled (#1145)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-31 18:54:20 +01:00
1 parent 9532664a25
commit 64dc621a7d
12 files changed
+415 -71

No files matched your search

@@ -674,6 +674,9 @@ public partial class ChatComponent : MSGComponentBase
}
var leftChatHadNoThread = !isFirstChat && this.loadedParameterChatId == Guid.Empty;
if (!isFirstChat)
await this.StopAnswerOfLeftChatAsync(this.loadedParameterChatId);
this.loadedParameterChatId = chatId;
this.loadedParameterWorkspaceId = workspaceId;
@@ -1580,9 +1583,15 @@ public partial class ChatComponent : MSGComponentBase
}
/// <summary>
/// Creates and stores the chat the composer belongs to, when there is none yet, e.g., for what an
/// attachment brings along before the first message is sent.
/// Creates the chat the composer belongs to, when there is none yet, e.g., for what an attachment
/// brings along before the first message is sent.
/// </summary>
/// <remarks>
/// Only while chats are stored automatically, the new chat reaches the disk right away. Otherwise, it
/// counts as unsaved, like a message typed into a new chat: a transcript made for it waits in the
/// staging area until the user saves the chat, see WorkspaceBehaviour.StoreChatAsync, and the next
/// start removes it when they never do.
/// </remarks>
/// <param name="nameWithoutInput">The name of the chat while the user typed nothing yet.</param>
/// <returns>The chat of the composer.</returns>
private async Task<ChatThread?> EnsureChatAsync(string nameWithoutInput)
@@ -1615,7 +1624,11 @@ public partial class ChatComponent : MSGComponentBase
// lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent:
//
await this.StoreAndListChatAsync(chat);
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.StoreAndListChatAsync(chat);
else
this.hasUnsavedChanges = true;
await this.SyncForegroundChatAsync();
return chat;
}
@@ -1766,7 +1779,7 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, this.ChatThread.RequiredOutboundDataRestriction.Restriction);
// From here on, the AI job stores the chat, in every storage mode:
// From here on, the AI job stores the chat, while chats are stored automatically:
this.unstoredChat = null;
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
@@ -1786,6 +1799,28 @@ public partial class ChatComponent : MSGComponentBase
await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId);
}
/// <summary>
/// Stops the answer which is still being written in a chat the user leaves, unless chats are stored automatically.
/// </summary>
/// <remarks>
/// Only while chats are stored automatically, the AI job stores the answer, so that the user finds
/// it when coming back. Otherwise, the answer would end up nowhere, and writing it would only cost
/// the user. The user was asked before, since an answer being written counts as an unsaved change
/// then.<br/><br/>
///
/// Called only where the user leaves a chat: opening another one, starting a new one, or leaving the
/// chat page. Never when this component goes away, since the page creates it anew, e.g., whenever the
/// workspace sidebar is shown or hidden, while the chat stays.
/// </remarks>
/// <param name="chatId">The chat being left.</param>
private async Task StopAnswerOfLeftChatAsync(Guid chatId)
{
if (chatId == Guid.Empty || this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
await this.AIJobService.CancelChatGenerationAsync(chatId);
}
/// <summary>
/// Takes over the tool selection of the chat that was just loaded or handed to this component.
/// </summary>
@@ -1825,7 +1860,21 @@ public partial class ChatComponent : MSGComponentBase
if (!this.CanThreadBeSaved)
return;
var stagedTranscripts = this.ChatThread.Draft.MediaTranscripts.Where(transcript => transcript.IsStaged).Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
await this.StoreAndListChatAsync(this.ChatThread);
//
// Storing moved the staged transcripts of the message being written into the chat directory,
// see WorkspaceBehaviour.StoreChatAsync. The composer still knows them by where they were, so
// sending the message would lose them:
//
if (stagedTranscripts.Count > 0)
{
this.ComposerState.FileAttachments.RemoveWhere(attachment => stagedTranscripts.Contains(attachment.FilePath));
foreach (var transcript in this.ChatThread.Draft.MediaTranscripts)
this.ComposerState.FileAttachments.Add(transcript);
}
this.hasUnsavedChanges = false;
// A new chat is on the disk now, like any other:
@@ -1876,9 +1925,11 @@ public partial class ChatComponent : MSGComponentBase
//
// Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss:
// about possible data loss. An answer still being written is lost as well, whenever chats are
// not stored automatically, since leaving stops it, see StopAnswerOfLeftChatAsync:
//
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming)
var storageBehavior = this.SettingsManager.ConfigurationData.Workspace.StorageBehavior;
if ((storageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) || (storageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY && this.IsCurrentChatStreaming))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
@@ -1930,6 +1981,9 @@ public partial class ChatComponent : MSGComponentBase
return false;
}
if (this.ChatThread is not null)
await this.StopAnswerOfLeftChatAsync(this.ChatThread.ChatId);
//
// Reset our state:
//
@@ -2034,19 +2088,6 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return;
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming)
{
var confirmationDialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, T("Are you sure you want to move this chat? All unsaved changes will be lost.") },
};
var confirmationDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Unsaved Changes"), confirmationDialogParameters, DialogOptions.FULLSCREEN);
var confirmationDialogResult = await confirmationDialogReference.Result;
if (confirmationDialogResult is null || confirmationDialogResult.Canceled)
return;
}
var dialogParameters = new DialogParameters<WorkspaceSelectionDialog>
{
{ x => x.Message, T("Please select the workspace where you want to move the chat to.") },
@@ -2065,13 +2106,15 @@ public partial class ChatComponent : MSGComponentBase
//
// Like saving and copying, the move goes through the workspace component whenever there is
// one, so its tree shows the chat in the new place right away:
// one, so its tree shows the chat in the new place right away. Nothing the user did not save
// gets lost, so nothing is asked: it reaches the new place only while chats are stored
// automatically, and otherwise stays unsaved, see WorkspaceBehaviour.MoveChatAsync:
//
var previousWorkspaceId = this.ChatThread!.WorkspaceId;
if (this.Workspaces is not null)
await this.Workspaces.MoveChatAsync(this.ChatThread!, workspaceId);
else
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId);
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId, this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
await this.FollowMovedChatAsync(previousWorkspaceId);
}
@@ -2204,6 +2247,9 @@ public partial class ChatComponent : MSGComponentBase
{
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
if (this.ChatThread is not null)
await this.StopAnswerOfLeftChatAsync(this.ChatThread.ChatId);
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.ClearWorkspaceHeaderState();
@@ -2283,8 +2329,11 @@ public partial class ChatComponent : MSGComponentBase
if (!this.blockActions.TryRemove(this.ChatThread, block))
return;
// Like every other change, stored right away only while chats are stored automatically:
this.hasUnsavedChanges = true;
await this.SaveThread();
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.SaveThread();
this.StateHasChanged();
}
@@ -2310,8 +2359,12 @@ public partial class ChatComponent : MSGComponentBase
// The rollback reset the AI-selected data sources, which the data source selection still shows:
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
// Like every other change, stored right away only while chats are stored automatically:
this.hasUnsavedChanges = true;
await this.SaveThread();
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.SaveThread();
this.tokenTracker?.Nudge();
this.StateHasChanged();
await this.inputField.FocusAsync();
@@ -2568,7 +2621,8 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread = this.AIJobService.TryGetLiveChatThread(snapshot.SubjectId) ?? this.ChatThread;
if (!snapshot.IsActive)
{
this.hasUnsavedChanges = false;
// The job stored the answer only while chats are stored automatically, see AIJobService.CheckpointChatAsync:
this.hasUnsavedChanges = this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY;
this.previousInputForbidden = true;
}
@@ -2587,8 +2641,9 @@ public partial class ChatComponent : MSGComponentBase
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) false));
// Nothing stores an answer being written now, and leaving stops it, see StopAnswerOfLeftChatAsync:
if (this.IsCurrentChatStreaming)
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) false));
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) true));
//
// A message the user started counts as unsaved on its own only while there is no chat
@@ -3,6 +3,7 @@ using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Dialogs;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
@@ -1009,11 +1010,14 @@ public partial class Workspaces : MSGComponentBase
/// Neither asks for the target workspace nor follows the chat in the chat view: both depend on
/// where the move was asked for. Whoever moves a chat while this component exists goes through
/// here, though, since the tree draws what it has built before and would otherwise keep showing
/// the chat in its old place.
/// the chat in its old place.<br/><br/>
///
/// What the user did not save reaches the new place only while chats are stored automatically,
/// see WorkspaceBehaviour.MoveChatAsync.
/// </remarks>
public async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId)
{
await WorkspaceBehaviour.MoveChatAsync(chat, targetWorkspaceId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetWorkspaceId, this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
@@ -1024,9 +1028,10 @@ public partial class Workspaces : MSGComponentBase
return;
//
// The open chat moves as it stands on the screen, just like with the button in the chat
// itself. The copy from the disk lacks whatever is not stored yet, such as a message being
// edited, and the chat would continue with that copy afterward:
// The open chat is the one which moves, just like with the button in the chat itself. The
// copy from the disk lacks whatever is not stored yet, such as a message being edited, and
// the chat would continue with that copy afterward. Whether that reaches the disk in the new
// place depends on how chats are stored, see MoveChatAsync:
//
var chat = this.CurrentChatThread is { } openChat && openChat.ChatId == storedChat.ChatId ? openChat : storedChat;
if (this.AIJobService.IsChatGenerationActive(chat.ChatId))