From 601fe727bad45654c10e26d2bafc85e933080bce Mon Sep 17 00:00:00 2001
From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:22:38 +0200
Subject: [PATCH 01/10] Added the ability to roll back to certain chat messages
and to copy an entire chat
---
.../Assistants/I18N/allTexts.lua | 24 +++++++
app/MindWork AI Studio/Chat/ChatThread.cs | 21 ++++++
.../Chat/ContentBlockComponent.razor | 6 ++
.../Chat/ContentBlockComponent.razor.cs | 23 +++++-
.../Components/ChatComponent.razor | 2 +
.../Components/ChatComponent.razor.cs | 18 +++++
.../Components/Workspaces.razor | 6 +-
.../Components/Workspaces.razor.cs | 31 ++++++++
.../plugin.lua | 24 +++++++
.../plugin.lua | 24 +++++++
.../Tools/WorkspaceBehaviour.cs | 72 +++++++++++++++++++
.../wwwroot/changelog/v26.9.1.md | 2 +
12 files changed, 251 insertions(+), 2 deletions(-)
diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index d3ea7596c..51cf59a84 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -3262,6 +3262,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Result"
-- Do you really want to remove this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Do you really want to remove this message?"
+-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed.
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."
+
-- Yes, remove the AI response and edit it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Yes, remove the AI response and edit it"
@@ -3313,12 +3316,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export
-- Number of attachments
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Number of attachments"
+-- Roll back to this response
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Roll back to this response"
+
-- Cannot render content of type {0} yet.
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Cannot render content of type {0} yet."
-- Edit
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Edit"
+-- Roll Back Chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Roll Back Chat"
+
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown"
@@ -3331,6 +3340,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"
+-- Yes, roll back the chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, roll back the chat"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
@@ -5281,6 +5293,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Copy Chat"
+
-- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..."
@@ -5353,6 +5368,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is alr
-- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name."
+-- Copy chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Copy chat"
+
-- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename"
@@ -12891,3 +12909,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you s
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
+
+-- Copy of {0}
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
+
+-- Empty chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"
diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs
index d01e1afa5..48be445a1 100644
--- a/app/MindWork AI Studio/Chat/ChatThread.cs
+++ b/app/MindWork AI Studio/Chat/ChatThread.cs
@@ -350,6 +350,27 @@ public void Remove(IContent content, bool removeForRegenerate = false)
this.Blocks.Remove(block);
}
+ ///
+ /// Removes every content block after the selected content in conversation order.
+ ///
+ /// The content to keep as the last block.
+ /// True when one or more later blocks were removed.
+ public bool RemoveBlocksAfter(IContent content)
+ {
+ var sortedBlocks = this.Blocks.OrderBy(x => x.Time).ToList();
+ var blockIndex = sortedBlocks.FindIndex(block => ReferenceEquals(block.Content, content));
+ if (blockIndex < 0 || blockIndex == sortedBlocks.Count - 1)
+ return false;
+
+ foreach (var block in sortedBlocks.Skip(blockIndex + 1))
+ {
+ DeleteManagedAttachments(block);
+ this.Blocks.Remove(block);
+ }
+
+ return true;
+ }
+
private static void DeleteManagedAttachments(ContentBlock block)
{
if (block.Content is not ContentText textContent)
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
index d1868ed22..0414e7956 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
@@ -70,6 +70,12 @@
}
+ @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 170d90f85..d6c0ee694 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -76,6 +76,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func? RegenerateFunc { get; set; }
+
+ [Parameter]
+ public Func? RollbackFunc { get; set; }
[Parameter]
public Func? EditLastBlockFunc { get; set; }
@@ -86,6 +89,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func RegenerateEnabled { get; set; } = () => false;
+ [Parameter]
+ public bool RollbackEnabled { get; set; }
+
///
/// What the export offers, used both as the label of the export button and as the title of
/// the save dialog.
@@ -780,6 +786,21 @@ private async Task RegenerateBlock()
if (regenerate.HasValue && regenerate.Value)
await this.RegenerateFunc(this.Content);
}
+
+ private async Task RollbackBlock()
+ {
+ if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.RollbackEnabled)
+ return;
+
+ var rollback = await this.DialogService.ShowMessageBox(
+ T("Roll Back Chat"),
+ T("Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."),
+ T("Yes, roll back the chat"),
+ T("No, keep it"));
+
+ if (rollback.HasValue && rollback.Value)
+ await this.RollbackFunc(this.Content);
+ }
private async Task EditLastBlock()
{
@@ -852,4 +873,4 @@ protected override async ValueTask DisposeResourcesAsync()
await this.DisposeMathContainerIfNeededAsync();
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor
index addb3536d..219910c82 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor
@@ -26,6 +26,8 @@
IsSecondToLastBlock="@isSecondLastBlock"
RegenerateFunc="@this.RegenerateBlock"
RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))"
+ RollbackFunc="@this.RollbackBlock"
+ RollbackEnabled="@(!this.IsCurrentChatStreaming)"
EditLastBlockFunc="@this.EditLastBlock"
EditLastUserBlockFunc="@this.EditLastUserBlock"/>
}
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index a11730ba7..6e0a19626 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -1394,6 +1394,24 @@ private async Task RegenerateBlock(IContent aiBlock)
await this.SendMessage(reuseLastUserPrompt: true);
}
+
+ private async Task RollbackBlock(IContent aiBlock)
+ {
+ if (this.ChatThread is null || this.IsCurrentChatStreaming)
+ return;
+
+ if (!this.ChatThread.RemoveBlocksAfter(aiBlock))
+ return;
+
+ this.ChatThread.AugmentedData = string.Empty;
+ this.ChatThread.AISelectedDataSources = [];
+ this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
+ this.hasUnsavedChanges = true;
+ await this.SaveThread();
+ this.tokenTracker?.Nudge();
+ this.StateHasChanged();
+ await this.inputField.FocusAsync();
+ }
private Task EditLastUserBlock(IContent block)
{
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor
index 74e52eddd..9a82c961f 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor
+++ b/app/MindWork AI Studio/Components/Workspaces.razor
@@ -81,6 +81,10 @@ else
+
+
+
+
@@ -148,4 +152,4 @@ else
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 05e9c3d92..1b1f99b9c 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -781,6 +781,37 @@ private async Task RenameChatAsync(string? chatPath)
await this.LoadTreeItemsAsync(startPrefetch: false);
}
+ private async Task CopyChatAsync(string? chatPath)
+ {
+ var chat = await this.LoadChatAsync(chatPath, false);
+ if (chat is null)
+ return;
+
+ var mediaOwner = MediaImportOwner.ForChat(chat.ChatId);
+ if (this.AIJobService.IsChatGenerationActive(chat.ChatId) || this.MediaTranscriptionService.IsBusy(mediaOwner))
+ return;
+
+ if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES))
+ {
+ var dialogParameters = new DialogParameters
+ {
+ { x => x.Message, T("Are you sure you want to load another chat? All unsaved changes will be lost.") },
+ };
+
+ var dialogReference = await this.DialogService.ShowAsync(T("Copy Chat"), dialogParameters, DialogOptions.FULLSCREEN);
+ var dialogResult = await dialogReference.Result;
+ if (dialogResult is null || dialogResult.Canceled)
+ return;
+ }
+
+ var sourceChat = this.CurrentChatThread is { } currentChat && currentChat.ChatId == chat.ChatId ? currentChat : chat;
+ var copy = await WorkspaceBehaviour.CopyChatAsync(sourceChat);
+
+ await this.LoadTreeItemsAsync(startPrefetch: false);
+ this.CurrentChatThread = copy;
+ await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
+ }
+
private async Task RenameWorkspaceAsync(string? workspacePath)
{
if (workspacePath is null)
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 c0e2d70dc..293b628bd 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
@@ -3264,6 +3264,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Ergebni
-- Do you really want to remove this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Möchten Sie diese Nachricht wirklich löschen?"
+-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed.
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Möchtest du diesen Chat wirklich auf diese KI-Antwort zurücksetzen? Alle späteren Nachrichten und deren Anhänge werden dauerhaft gelöscht."
+
-- Yes, remove the AI response and edit it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Ja, entferne die KI-Antwort und bearbeite sie."
@@ -3315,12 +3318,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "KI-Antw
-- Number of attachments
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Anzahl der Anhänge"
+-- Roll back to this response
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Zu dieser Antwort zurücksetzen"
+
-- Cannot render content of type {0} yet.
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Der Inhaltstyp {0} kann noch nicht angezeigt werden."
-- Edit
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Bearbeiten"
+-- Roll Back Chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Chat zurücksetzen"
+
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unbekannt"
@@ -3333,6 +3342,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockie
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?"
+-- Yes, roll back the chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Ja, Chat zurücksetzen"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachricht entfernen"
@@ -5283,6 +5295,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Chat kopieren"
+
-- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Chats werden geladen..."
@@ -5355,6 +5370,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "Es gibt bere
-- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein."
+-- Copy chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Chat kopieren"
+
-- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen"
@@ -12893,3 +12911,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchten
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat"
+
+-- Copy of {0}
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Kopie von {0}"
+
+-- Empty chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Leerer Chat"
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 4725c5246..4b26268b9 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
@@ -3264,6 +3264,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Result"
-- Do you really want to remove this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Do you really want to remove this message?"
+-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed.
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."
+
-- Yes, remove the AI response and edit it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Yes, remove the AI response and edit it"
@@ -3315,12 +3318,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export
-- Number of attachments
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Number of attachments"
+-- Roll back to this response
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Roll back to this response"
+
-- Cannot render content of type {0} yet.
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Cannot render content of type {0} yet."
-- Edit
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Edit"
+-- Roll Back Chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Roll Back Chat"
+
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown"
@@ -3333,6 +3342,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked
-- Do you really want to regenerate this message?
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?"
+-- Yes, roll back the chat
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, roll back the chat"
+
-- Remove Message
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message"
@@ -5283,6 +5295,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Copy Chat"
+
-- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..."
@@ -5355,6 +5370,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is alr
-- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name."
+-- Copy chat
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Copy chat"
+
-- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename"
@@ -12893,3 +12911,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you s
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
+
+-- Copy of {0}
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
+
+-- Empty chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"
diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
index 068f369c7..8bb00ef27 100644
--- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
+++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
@@ -782,6 +782,78 @@ public static async Task StoreChatAsync(ChatThread chat)
}
}
+ public static async Task CopyChatAsync(ChatThread sourceChat)
+ {
+ var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
+ var copiedChat = JsonSerializer.Deserialize(serializedChat, JSON_OPTIONS)
+ ?? throw new InvalidOperationException("The chat could not be copied.");
+ copiedChat = copiedChat with
+ {
+ ChatId = Guid.NewGuid(),
+ Name = string.Format(TB("Copy of {0}"), string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Empty chat") : sourceChat.Name),
+ };
+
+ var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
+ try
+ {
+ CopyManagedTranscriptAttachments(copiedChat, targetDirectory);
+ await StoreChatAsync(copiedChat);
+ return copiedChat;
+ }
+ catch
+ {
+ if (Directory.Exists(targetDirectory))
+ Directory.Delete(targetDirectory, true);
+
+ InvalidateWorkspaceTreeCache();
+ throw;
+ }
+ }
+
+ private static void CopyManagedTranscriptAttachments(ChatThread chat, string targetChatDirectory)
+ {
+ var targetTranscriptDirectory = Path.Combine(targetChatDirectory, "attachments", "transcripts");
+ var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+ var copiedPaths = new Dictionary(pathComparer);
+
+ foreach (var content in chat.Blocks.Select(block => block.Content).OfType())
+ {
+ for (var index = 0; index < content.FileAttachments.Count; index++)
+ {
+ if (content.FileAttachments[index] is ManagedTranscriptAttachment transcript)
+ content.FileAttachments[index] = CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths);
+ }
+ }
+
+ for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
+ chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
+ }
+
+ private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
+ ChatThread chat,
+ ManagedTranscriptAttachment source,
+ string targetTranscriptDirectory,
+ Dictionary copiedPaths)
+ {
+ var sourcePath = Path.GetFullPath(source.FilePath);
+ if (copiedPaths.TryGetValue(sourcePath, out var existingCopy))
+ return existingCopy;
+
+ Directory.CreateDirectory(targetTranscriptDirectory);
+ var targetPath = NextTranscriptPath(chat, targetTranscriptDirectory, source.OriginalFileName);
+ if (File.Exists(sourcePath))
+ File.Copy(sourcePath, targetPath);
+
+ var copiedAttachment = new ManagedTranscriptAttachment(
+ Path.GetFileName(targetPath),
+ targetPath,
+ File.Exists(targetPath) ? new FileInfo(targetPath).Length : source.FileSizeBytes,
+ source.OriginalFileName,
+ false);
+ copiedPaths[sourcePath] = copiedAttachment;
+ return copiedAttachment;
+ }
+
/// Creates a transcript atomically inside an already persisted chat.
/// Persisted chat that owns the transcript counter.
/// Original media path.
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
index e0d15144d..45f932100 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -1,4 +1,6 @@
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
+- Added a way to copy an entire chat and continue the copy independently while the original conversation stays unchanged.
+- Added a way to roll a chat back to an earlier AI response. The response you choose stays, everything after it is permanently removed, and your current draft and attachments remain ready so you can continue from there.
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
From 34774531fde27587e665c06aa314c8ed1094ef6a Mon Sep 17 00:00:00 2001
From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:43:59 +0200
Subject: [PATCH 02/10] Personal Code Review
---
.../Assistants/I18N/allTexts.lua | 6 +++
.../Chat/ContentBlockComponent.razor | 2 +-
.../Chat/ContentBlockComponent.razor.cs | 6 +--
.../Components/ChatComponent.razor | 2 +-
.../Components/ChatComponent.razor.cs | 10 ++++
.../Components/Workspaces.razor | 2 +-
.../Components/Workspaces.razor.cs | 18 +++++++-
.../plugin.lua | 8 +++-
.../plugin.lua | 6 +++
.../Tools/WorkspaceBehaviour.cs | 46 +++++++++++++++++--
.../wwwroot/changelog/v26.9.1.md | 4 +-
11 files changed, 95 insertions(+), 15 deletions(-)
diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 51cf59a84..4a9a0ccd1 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -5290,6 +5290,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transc
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?"
+-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with."
+
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
@@ -5386,6 +5389,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Load Chat"
-- Add Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspace"
+-- 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."
+
-- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name"
diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
index 0414e7956..71d5729de 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor
@@ -73,7 +73,7 @@
@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 d6c0ee694..4dc68e92a 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -90,7 +90,7 @@ public partial class ContentBlockComponent : MSGComponentBase
public Func RegenerateEnabled { get; set; } = () => false;
[Parameter]
- public bool RollbackEnabled { get; set; }
+ public Func RollbackEnabled { get; set; } = () => false;
///
/// What the export offers, used both as the label of the export button and as the title of
@@ -789,7 +789,7 @@ private async Task RegenerateBlock()
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.RollbackEnabled())
return;
var rollback = await this.DialogService.ShowMessageBox(
@@ -873,4 +873,4 @@ protected override async ValueTask DisposeResourcesAsync()
await this.DisposeMathContainerIfNeededAsync();
}
-}
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor
index 219910c82..903b57cf9 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor
@@ -27,7 +27,7 @@
RegenerateFunc="@this.RegenerateBlock"
RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))"
RollbackFunc="@this.RollbackBlock"
- RollbackEnabled="@(!this.IsCurrentChatStreaming)"
+ RollbackEnabled="@(() => !this.IsCurrentChatStreaming)"
EditLastBlockFunc="@this.EditLastBlock"
EditLastUserBlockFunc="@this.EditLastUserBlock"/>
}
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index 6e0a19626..0d3cae2b3 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -1403,6 +1403,16 @@ private async Task RollbackBlock(IContent aiBlock)
if (!this.ChatThread.RemoveBlocksAfter(aiBlock))
return;
+ //
+ // Only the working state of the next turn is reset here: the augmented data and the
+ // AI-selected data sources are rebuilt by the RAG process anyway, so carrying the ones of a
+ // removed message over would just put stale context into the system prompt.
+ //
+ // What stays is everything the thread ratchets for security reasons, namely DataSecurity and
+ // RequiredProviderConfidence. Both only ever tighten, because the data which raised them was
+ // seen by this thread. Removing the message that brought it in does not unsee it, so the
+ // chat keeps demanding the same of every provider which continues it.
+ //
this.ChatThread.AugmentedData = string.Empty;
this.ChatThread.AISelectedDataSources = [];
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor
index 9a82c961f..4ea4b401b 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor
+++ b/app/MindWork AI Studio/Components/Workspaces.razor
@@ -152,4 +152,4 @@ else
}
-}
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 1b1f99b9c..3cfd8930c 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -791,11 +791,25 @@ private async Task CopyChatAsync(string? chatPath)
if (this.AIJobService.IsChatGenerationActive(chat.ChatId) || this.MediaTranscriptionService.IsBusy(mediaOwner))
return;
+ //
+ // Copying the chat which is open right now takes its in-memory state, so whatever the user
+ // has not saved yet ends up in the copy while the original keeps the state it was stored
+ // with. Copying any other chat replaces the open one, so its unsaved changes are gone.
+ // Both outcomes are surprising enough to deserve their own wording.
+ //
+ var openChat = this.CurrentChatThread;
+ var isCopyOfOpenChat = openChat is not null && openChat.ChatId == chat.ChatId;
if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters
{
- { x => x.Message, T("Are you sure you want to load another chat? All unsaved changes will be lost.") },
+ {
+ x => x.Message, isCopyOfOpenChat switch
+ {
+ true => T("Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with."),
+ false => T("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."),
+ }
+ },
};
var dialogReference = await this.DialogService.ShowAsync(T("Copy Chat"), dialogParameters, DialogOptions.FULLSCREEN);
@@ -804,7 +818,7 @@ private async Task CopyChatAsync(string? chatPath)
return;
}
- var sourceChat = this.CurrentChatThread is { } currentChat && currentChat.ChatId == chat.ChatId ? currentChat : chat;
+ var sourceChat = isCopyOfOpenChat ? openChat! : chat;
var copy = await WorkspaceBehaviour.CopyChatAsync(sourceChat);
await this.LoadTreeItemsAsync(startPrefetch: false);
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 293b628bd..2b36850c2 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
@@ -3265,7 +3265,7 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Ergebni
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Möchten Sie diese Nachricht wirklich löschen?"
-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed.
-UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Möchtest du diesen Chat wirklich auf diese KI-Antwort zurücksetzen? Alle späteren Nachrichten und deren Anhänge werden dauerhaft gelöscht."
+UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Möchten Sie diesen Chat wirklich auf diese KI-Antwort zurücksetzen? Alle späteren Nachrichten und deren Anhänge werden dauerhaft gelöscht."
-- Yes, remove the AI response and edit it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Ja, entferne die KI-Antwort und bearbeite sie."
@@ -5292,6 +5292,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebn
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie den Chat „{0}“ im Arbeitsbereich „{1}“ wirklich löschen?"
+-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Möchten Sie diesen Chat kopieren? Ihre ungespeicherten Änderungen wandern in die Kopie, und der ursprüngliche Chat behält den zuletzt gespeicherten Stand."
+
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben"
@@ -5388,6 +5391,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Chat laden"
-- Add Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Arbeitsbereich hinzufügen"
+-- 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."
+
-- Chat Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Name des Chat"
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 4b26268b9..beaf3073b 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
@@ -5292,6 +5292,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transc
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?"
+-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with."
+
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
@@ -5388,6 +5391,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Load Chat"
-- Add Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspace"
+-- 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."
+
-- 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 8bb00ef27..a1ca2f771 100644
--- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
+++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
@@ -752,11 +752,16 @@ public static bool IsChatExisting(LoadChat loadChat)
return Directory.Exists(chatPath);
}
- public static async Task StoreChatAsync(ChatThread chat)
+ ///
+ /// Stores a chat, unless another operation holds its lock for longer than the semaphore timeout.
+ ///
+ /// The chat to store.
+ /// True when the chat was written; false when the operation was skipped to avoid a race.
+ public static async Task StoreChatAsync(ChatThread chat)
{
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync));
if (!acquired)
- return;
+ return false;
try
{
@@ -775,6 +780,7 @@ public static async Task StoreChatAsync(ChatThread chat)
var lastEditTime = File.GetLastWriteTimeUtc(chatPath);
await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime);
+ return true;
}
finally
{
@@ -782,6 +788,15 @@ public static async Task StoreChatAsync(ChatThread chat)
}
}
+ ///
+ /// Copies a chat into a new chat of the same workspace, including its managed transcript files.
+ ///
+ /// The chat to copy. Its own files and state stay untouched.
+ /// The persisted copy.
+ ///
+ /// The copy is written before it is returned, so the caller may open it right away. Runtime-only
+ /// state of the source is not part of the copy: it is rebuilt when the copy gets loaded.
+ ///
public static async Task CopyChatAsync(ChatThread sourceChat)
{
var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
@@ -797,7 +812,15 @@ public static async Task CopyChatAsync(ChatThread sourceChat)
try
{
CopyManagedTranscriptAttachments(copiedChat, targetDirectory);
- await StoreChatAsync(copiedChat);
+
+ //
+ // Storing is skipped instead of failing when the chat lock cannot be taken. For a copy
+ // that must not pass as success: the caller would open a chat which is not on disk,
+ // while the transcript files copied above would stay behind as orphans.
+ //
+ if (!await StoreChatAsync(copiedChat))
+ throw new IOException($"The copied chat could not be stored: '{targetDirectory}'.");
+
return copiedChat;
}
catch
@@ -835,7 +858,22 @@ private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
string targetTranscriptDirectory,
Dictionary copiedPaths)
{
- var sourcePath = Path.GetFullPath(source.FilePath);
+ //
+ // A thread which was edited outside the app may name a path which is not a path at all.
+ // Such an attachment keeps pointing at whatever the source named: it is already broken in
+ // the source chat, and letting it take the whole copy down would be worse.
+ //
+ string sourcePath;
+ try
+ {
+ sourcePath = Path.GetFullPath(source.FilePath);
+ }
+ catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ LOG.LogWarning(e, "Could not resolve the transcript path '{FilePath}' while copying chat '{ChatId}'. The attachment is kept as it is.", source.FilePath, chat.ChatId);
+ return source;
+ }
+
if (copiedPaths.TryGetValue(sourcePath, out var existingCopy))
return existingCopy;
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
index 45f932100..108da19d9 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -1,6 +1,6 @@
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
-- Added a way to copy an entire chat and continue the copy independently while the original conversation stays unchanged.
-- Added a way to roll a chat back to an earlier AI response. The response you choose stays, everything after it is permanently removed, and your current draft and attachments remain ready so you can continue from there.
+- Added a way to copy an entire chat. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was.
+- Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages.
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
From 6e559145ef0da16f94e5110990d2bb41c4163a21 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 09:36:02 +0200
Subject: [PATCH 03/10] Moved the rollback rules into the chat thread
---
app/MindWork AI Studio/Chat/ChatThread.cs | 27 ++++++++++++++++---
.../Components/ChatComponent.razor.cs | 16 +++--------
2 files changed, 27 insertions(+), 16 deletions(-)
diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs
index 48be445a1..466fbe448 100644
--- a/app/MindWork AI Studio/Chat/ChatThread.cs
+++ b/app/MindWork AI Studio/Chat/ChatThread.cs
@@ -351,11 +351,30 @@ public void Remove(IContent content, bool removeForRegenerate = false)
}
///
- /// Removes every content block after the selected content in conversation order.
+ /// Rolls this chat thread back, so that the given content becomes the last block of the conversation.
///
+ ///
+ /// Every later block is removed in conversation order, which is the order of the time stamps and
+ /// the order in which the chat shows the blocks. That includes the blocks hidden from the user,
+ /// such as the prompts an assistant sends into a chat: the user can neither see nor remove them,
+ /// so leaving them behind would continue the chat with messages nobody knows about. Hidden blocks
+ /// before the content stay, e.g. the example conversation of a chat template. The managed
+ /// transcripts of the removed blocks are deleted with them.
+ ///
+ /// The augmented data and the AI-selected data sources are reset, too. Both describe the last
+ /// retrieval, not a certain message, so after a rollback nobody knows whether they belong to a
+ /// kept or to a removed one. The next message with active data sources retrieves anew; without
+ /// active data sources, the chat continues without this context. The data source options stay,
+ /// because they are the user's choice rather than the result of a message.
+ ///
+ /// What stays as well is everything the thread ratchets for security reasons, namely the data
+ /// security and the required provider confidence. Both only ever tighten, because the data which
+ /// raised them was seen by this thread. Removing the message that brought it in does not unsee
+ /// it, so the chat keeps demanding the same of every provider which continues it.
+ ///
/// The content to keep as the last block.
- /// True when one or more later blocks were removed.
- public bool RemoveBlocksAfter(IContent content)
+ /// True when one or more later blocks were removed. False when the content is unknown or already the last block; the thread stays unchanged then.
+ public bool RollBackTo(IContent content)
{
var sortedBlocks = this.Blocks.OrderBy(x => x.Time).ToList();
var blockIndex = sortedBlocks.FindIndex(block => ReferenceEquals(block.Content, content));
@@ -368,6 +387,8 @@ public bool RemoveBlocksAfter(IContent content)
this.Blocks.Remove(block);
}
+ this.AugmentedData = string.Empty;
+ this.AISelectedDataSources = [];
return true;
}
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index 3b331f734..a28509f1d 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -1463,21 +1463,11 @@ private async Task RollbackBlock(IContent aiBlock)
if (this.ChatThread is null || this.IsCurrentChatStreaming)
return;
- if (!this.ChatThread.RemoveBlocksAfter(aiBlock))
+ // Which parts of the thread a rollback resets and which it keeps is documented at RollBackTo:
+ if (!this.ChatThread.RollBackTo(aiBlock))
return;
- //
- // Only the working state of the next turn is reset here: the augmented data and the
- // AI-selected data sources are rebuilt by the RAG process anyway, so carrying the ones of a
- // removed message over would just put stale context into the system prompt.
- //
- // What stays is everything the thread ratchets for security reasons, namely DataSecurity and
- // RequiredProviderConfidence. Both only ever tighten, because the data which raised them was
- // seen by this thread. Removing the message that brought it in does not unsee it, so the
- // chat keeps demanding the same of every provider which continues it.
- //
- this.ChatThread.AugmentedData = string.Empty;
- this.ChatThread.AISelectedDataSources = [];
+ // The rollback reset the AI-selected data sources, which the data source selection still shows:
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
this.hasUnsavedChanges = true;
await this.SaveThread();
From 6b845e45d4427c66962adc8786f75340c1a3a07f Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 09:57:18 +0200
Subject: [PATCH 04/10] Added tests for rolling back a chat
---
app/Tests/Chat/ChatThreadRollbackTests.cs | 225 ++++++++++++++++++++++
1 file changed, 225 insertions(+)
create mode 100644 app/Tests/Chat/ChatThreadRollbackTests.cs
diff --git a/app/Tests/Chat/ChatThreadRollbackTests.cs b/app/Tests/Chat/ChatThreadRollbackTests.cs
new file mode 100644
index 000000000..0f9e91f19
--- /dev/null
+++ b/app/Tests/Chat/ChatThreadRollbackTests.cs
@@ -0,0 +1,225 @@
+using AIStudio.Agents;
+using AIStudio.Chat;
+using AIStudio.Components;
+using AIStudio.Provider;
+using AIStudio.Settings.DataModel;
+
+namespace AIStudio.Tests.Chat;
+
+///
+/// Checks what rolling a chat back to an earlier answer removes and what it keeps.
+///
+///
+/// A rollback has to remove more than the user sees. The prompts an assistant sends into a chat are
+/// hidden, yet they are part of the conversation; a chat which kept them would continue with
+/// messages the user can neither see nor remove. The example conversation of a chat template is
+/// hidden as well, but it comes before everything else and has to keep working.
+///
+/// What a rollback must not remove is anything which tells the providers what this chat has seen.
+/// Removing the message which brought confidential data in does not unsee it, so the chat keeps
+/// demanding a self-hosted provider, and the confidence which that data asked for.
+///
+[TestFixture]
+public sealed class ChatThreadRollbackTests
+{
+ private static readonly DateTimeOffset START = new(2026, 9, 23, 10, 0, 0, TimeSpan.Zero);
+
+ [Test]
+ public void HiddenPromptsAfterTheAnswerGoAsWell()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 2);
+ var thread = new ChatThread
+ {
+ Blocks =
+ [
+ Block(ChatRole.USER, "First question", 1),
+ answer,
+ Block(ChatRole.USER, "Prompt of an assistant", 3, hidden: true),
+ Block(ChatRole.AI, "Answer to the assistant", 4),
+ ],
+ };
+
+ var rolledBack = thread.RollBackTo(answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rolledBack, Is.True, "Two blocks came after the answer, so the rollback removed something.");
+ Assert.That(Texts(thread), Is.EqualTo(new[] { "First question", "First answer" }), "The hidden prompt goes with the answer to it, although the user never saw it.");
+ });
+ }
+
+ [Test]
+ public void TheExampleConversationOfAChatTemplateStays()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 4);
+ var thread = new ChatThread
+ {
+ Blocks =
+ [
+ Block(ChatRole.USER, "Example question", 1, hidden: true),
+ Block(ChatRole.AI, "Example answer", 2, hidden: true),
+ Block(ChatRole.USER, "First question", 3),
+ answer,
+ Block(ChatRole.USER, "Second question", 5),
+ Block(ChatRole.AI, "Second answer", 6),
+ ],
+ };
+
+ thread.RollBackTo(answer.Content!);
+
+ Assert.That(Texts(thread), Is.EqualTo(new[] { "Example question", "Example answer", "First question", "First answer" }), "Hidden blocks before the answer belong to what the user rolls back to, so the chat template keeps its example conversation.");
+ }
+
+ [Test]
+ public void TheOrderIsTheOneOfTheTimeStampsNotTheOneOfTheList()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 2);
+ var thread = new ChatThread
+ {
+ Blocks =
+ [
+ Block(ChatRole.USER, "Second question", 3),
+ Block(ChatRole.AI, "Second answer", 4),
+ answer,
+ Block(ChatRole.USER, "First question", 1),
+ ],
+ };
+
+ thread.RollBackTo(answer.Content!);
+
+ Assert.That(Texts(thread), Is.EqualTo(new[] { "First question", "First answer" }), "The chat shows its blocks by time, so a rollback has to count by time as well, wherever a block sits in the list.");
+ }
+
+ [Test]
+ public void TheLastRetrievalIsDroppedButTheChoiceOfDataSourcesStays()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 2);
+ var options = new DataSourceOptions
+ {
+ DisableDataSources = false,
+ AutomaticDataSourceSelection = true,
+ PreselectedDataSourceIds = ["handbook"],
+ };
+
+ var thread = new ChatThread
+ {
+ DataSourceOptions = options,
+ AISelectedDataSources = [SelectedHandbook()],
+ AugmentedData = "A chunk from the handbook",
+ Blocks =
+ [
+ Block(ChatRole.USER, "First question", 1),
+ answer,
+ Block(ChatRole.USER, "Second question", 3),
+ Block(ChatRole.AI, "Second answer", 4),
+ ],
+ };
+
+ thread.RollBackTo(answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(thread.AugmentedData, Is.Empty, "Nobody knows whether the retrieved data belongs to a kept or to a removed message, so it must not reach the next system prompt.");
+ Assert.That(thread.AISelectedDataSources, Is.Empty, "The data sources an agent picked belong to the same retrieval as the data.");
+ Assert.That(thread.DataSourceOptions, Is.SameAs(options), "The data source options are the user's choice, not the result of a message.");
+ Assert.That(options.DisableDataSources, Is.False);
+ Assert.That(options.AutomaticDataSourceSelection, Is.True);
+ Assert.That(options.PreselectedDataSourceIds, Is.EqualTo(new[] { "handbook" }));
+ });
+ }
+
+ [Test]
+ public void WhatTheChatHasSeenKeepsRestrictingTheProviders()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 2);
+ var thread = new ChatThread
+ {
+ DataSecurity = DataSourceSecurity.SELF_HOSTED,
+ Blocks =
+ [
+ Block(ChatRole.USER, "First question", 1),
+ answer,
+ Block(ChatRole.USER, "Question about the confidential handbook", 3),
+ Block(ChatRole.AI, "Answer from the confidential handbook", 4),
+ ],
+ };
+
+ thread.RequireProviderConfidence(ConfidenceLevel.HIGH);
+
+ thread.RollBackTo(answer.Content!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(thread.DataSecurity, Is.EqualTo(DataSourceSecurity.SELF_HOSTED), "The confidential data was seen by this chat, so no cloud provider may continue it.");
+ Assert.That(thread.RequiredProviderConfidence, Is.EqualTo(ConfidenceLevel.HIGH), "The confidence the data demanded stays, although the message which brought it in is gone.");
+ });
+ }
+
+ [Test]
+ public void RollingBackToTheLastAnswerChangesNothing()
+ {
+ var answer = Block(ChatRole.AI, "First answer", 2);
+ var thread = ThreadWithRetrieval(Block(ChatRole.USER, "First question", 1), answer);
+
+ var rolledBack = thread.RollBackTo(answer.Content!);
+
+ AssertUnchanged(thread, rolledBack, "First question", "First answer");
+ }
+
+ [Test]
+ public void RollingBackToUnknownContentChangesNothing()
+ {
+ var thread = ThreadWithRetrieval(Block(ChatRole.USER, "First question", 1), Block(ChatRole.AI, "First answer", 2));
+
+ var rolledBack = thread.RollBackTo(new ContentText { Text = "Answer of another chat" });
+
+ AssertUnchanged(thread, rolledBack, "First question", "First answer");
+ }
+
+ ///
+ /// A chat whose last retrieval is still in place, so that a rollback which should do nothing
+ /// shows when it resets it anyway.
+ ///
+ /// The blocks of the chat.
+ /// The chat.
+ private static ChatThread ThreadWithRetrieval(params ContentBlock[] blocks) => new()
+ {
+ AISelectedDataSources = [SelectedHandbook()],
+ AugmentedData = "A chunk from the handbook",
+ Blocks = [..blocks],
+ };
+
+ private static void AssertUnchanged(ChatThread thread, bool rolledBack, params string[] expectedTexts)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(rolledBack, Is.False, "Nothing came after the content, so there was nothing to roll back.");
+ Assert.That(Texts(thread), Is.EqualTo(expectedTexts), "A rollback without anything to remove leaves the blocks alone.");
+ Assert.That(thread.AugmentedData, Is.EqualTo("A chunk from the handbook"), "A rollback without anything to remove keeps the last retrieval, because it still belongs to the last message.");
+ Assert.That(thread.AISelectedDataSources, Has.Count.EqualTo(1));
+ });
+ }
+
+ private static ContentBlock Block(ChatRole role, string text, int minute, bool hidden = false) => new()
+ {
+ Time = START.AddMinutes(minute),
+ ContentType = ContentType.TEXT,
+ Content = new ContentText { Text = text },
+ Role = role,
+ HideFromUser = hidden,
+ };
+
+ private static DataSourceAgentSelected SelectedHandbook() => new()
+ {
+ DataSource = new DataSourceLocalDirectory { Id = "handbook", Name = "Handbook" },
+ AIDecision = new SelectedDataSource("handbook", "The question is about the handbook.", 0.9f),
+ Selected = true,
+ };
+
+ ///
+ /// The texts of the chat in the order the chat shows them.
+ ///
+ /// The chat.
+ /// The texts.
+ private static string[] Texts(ChatThread thread) => thread.Blocks.OrderBy(x => x.Time).Select(x => ((ContentText)x.Content!).Text).ToArray();
+}
\ No newline at end of file
From 8ac23231ec1d4973b55ddb0e21e6edf73a1e4159 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:05:20 +0200
Subject: [PATCH 05/10] Added a name question when copying a chat
---
.../Assistants/I18N/allTexts.lua | 16 +++++++--
.../Components/Workspaces.razor.cs | 4 ++-
.../Tools/WorkspaceBehaviour.cs | 33 ++++++++++++++++---
3 files changed, 45 insertions(+), 8 deletions(-)
diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 66064dcb3..a2d726f0b 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -12973,20 +12973,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] =
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Copy Chat"
+
-- Unnamed workspace
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed workspace"
+-- Copy
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Copy"
+
-- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"
+-- Please enter a chat name.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Please enter a chat name."
+
-- Are you sure you want to delete the temporary chat '{0}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you sure you want to delete the temporary chat '{0}'?"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
+-- Please enter a name for the copy of your chat '{0}':
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Please enter a name for the copy of your chat '{0}':"
+
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
--- Empty chat
-UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"
+-- Chat Name
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3891063690"] = "Chat Name"
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 73f85291a..801b197c3 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -819,7 +819,9 @@ private async Task CopyChatAsync(string? chatPath)
}
var sourceChat = isCopyOfOpenChat ? openChat! : chat;
- var copy = await WorkspaceBehaviour.CopyChatAsync(sourceChat);
+ var copy = await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat);
+ if (copy is null)
+ return;
await this.LoadTreeItemsAsync(startPrefetch: false);
this.CurrentChatThread = copy;
diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
index 519cff7cf..6a9c14da2 100644
--- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
+++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs
@@ -789,23 +789,46 @@ public static async Task StoreChatAsync(ChatThread chat)
}
///
- /// Copies a chat into a new chat of the same workspace, including its managed transcript files.
+ /// Copies a chat into a new chat of the same workspace, including its managed transcript files,
+ /// after asking the user for the name of the copy.
///
+ /// Used to ask for the name.
/// The chat to copy. Its own files and state stay untouched.
- /// The persisted copy.
+ /// The persisted copy. Null when the user canceled the question, in which case nothing was copied.
///
+ /// This is the one place that asks for the name of a copy, so every way of copying a chat
+ /// suggests the same name and words the question the same way.
+ ///
/// The copy is written before it is returned, so the caller may open it right away. Runtime-only
- /// state of the source is not part of the copy: it is rebuilt when the copy gets loaded.
+ /// state of the source is not part of the copy: it is rebuilt when the copy gets loaded. When
+ /// the copy cannot be written, nothing of it stays behind and the error reaches the caller.
///
- public static async Task CopyChatAsync(ChatThread sourceChat)
+ public static async Task CopyChatAsync(IDialogService dialogService, ChatThread sourceChat)
{
+ var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name;
+ var dialogParameters = new DialogParameters
+ {
+ { x => x.Message, string.Format(TB("Please enter a name for the copy of your chat '{0}':"), sourceName) },
+ { x => x.InputHeaderText, TB("Chat Name") },
+ { x => x.UserInput, string.Format(TB("Copy of {0}"), sourceName) },
+ { x => x.ConfirmText, TB("Copy") },
+ { x => x.ConfirmColor, Color.Info },
+ { x => x.AllowEmptyInput, false },
+ { x => x.EmptyInputErrorMessage, TB("Please enter a chat name.") },
+ };
+
+ var dialogReference = await dialogService.ShowAsync(TB("Copy Chat"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
+ var dialogResult = await dialogReference.Result;
+ if (dialogResult is null || dialogResult.Canceled)
+ return null;
+
var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
var copiedChat = JsonSerializer.Deserialize(serializedChat, JSON_OPTIONS)
?? throw new InvalidOperationException("The chat could not be copied.");
copiedChat = copiedChat with
{
ChatId = Guid.NewGuid(),
- Name = string.Format(TB("Copy of {0}"), string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Empty chat") : sourceChat.Name),
+ Name = (dialogResult.Data as string)!,
};
var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
From d3ee7dbbec693d2e186d16f3958e38ac13a7dda6 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:19:42 +0200
Subject: [PATCH 06/10] Added copying a chat from the chat toolbar
---
.../Components/ChatComponent.razor | 4 ++
.../Components/ChatComponent.razor.cs | 47 ++++++++++++++++++-
.../Components/Workspaces.razor.cs | 21 ++++++++-
3 files changed, 68 insertions(+), 4 deletions(-)
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor
index f1fe5039a..c27291beb 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor
@@ -103,6 +103,10 @@
+
+
+
+
}
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index a28509f1d..f4c017aa6 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -649,7 +649,9 @@ private string InputLabel
}
private bool CanThreadBeSaved => this.ChatThread is not null && this.ChatThread.Blocks.Any(b => !b.HideFromUser);
-
+
+ private bool CanThreadBeCopied => this.CanThreadBeSaved && !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
+
private string TooltipAddChatToWorkspace => string.Format(T("Start new chat in workspace '{0}'"), this.currentWorkspaceName);
private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty;
@@ -1344,7 +1346,48 @@ private async Task MoveChatToWorkspace()
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
-
+
+ ///
+ /// Copies the open chat and continues in the copy.
+ ///
+ ///
+ /// Copied is the chat as it stands on the screen, not the state which was stored last. That is
+ /// why nothing is asked about unsaved changes here, unlike everywhere else a chat is left
+ /// behind: none of them are lost, they move into the copy, while the original keeps what was
+ /// stored.
+ ///
+ /// The same holds for the message being written. The copy is marked as loaded right away,
+ /// because the parameter update which follows would otherwise take it for another chat being
+ /// opened and clear the composer. Only the transcripts attached to that message are exchanged:
+ /// those of the original live in its directory, and the copy got copies of its own.
+ ///
+ private async Task CopyCurrentChat()
+ {
+ if (this.ChatThread is null || !this.CanThreadBeCopied)
+ return;
+
+ var sourceChat = this.ChatThread;
+ var copy = this.Workspaces is null
+ ? await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat)
+ : await this.Workspaces.CopyChatAsync(sourceChat);
+
+ if (copy is null)
+ return;
+
+ var transcriptsOfTheOriginal = sourceChat.PendingMediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
+ this.ComposerState.FileAttachments.RemoveWhere(attachment => transcriptsOfTheOriginal.Contains(attachment.FilePath));
+ foreach (var transcript in copy.PendingMediaTranscripts)
+ this.ComposerState.FileAttachments.Add(transcript);
+
+ this.ChatThread = copy;
+ this.hasUnsavedChanges = false;
+
+ await this.SyncForegroundChatAsync();
+ this.MarkCurrentChatAsLoadedParameter();
+ await this.SyncWorkspaceHeaderWithChatThreadAsync();
+ await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
+ }
+
private async Task LoadedChatChanged(bool notifyParent = true)
{
this.hasUnsavedChanges = false;
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 801b197c3..240f7dfbf 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -660,6 +660,24 @@ public async Task StoreChatAsync(ChatThread chat, bool reloadTreeItems = false)
await this.LoadTreeItemsAsync(startPrefetch: false);
}
+ ///
+ /// Copies the given chat, after asking the user for the name of the copy, and shows the copy in the tree.
+ ///
+ /// The chat to copy, as it stands in memory.
+ /// The persisted copy. Null when the user canceled the question, in which case nothing was copied.
+ ///
+ /// Neither asks about unsaved changes nor opens the copy: which of both a caller needs depends
+ /// on where the copy was asked for.
+ ///
+ public async Task CopyChatAsync(ChatThread sourceChat)
+ {
+ var copy = await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat);
+ if (copy is not null)
+ await this.LoadTreeItemsAsync(startPrefetch: false);
+
+ return copy;
+ }
+
private async Task LoadChatAsync(string? chatPath, bool switchToChat)
{
if (string.IsNullOrWhiteSpace(chatPath))
@@ -819,11 +837,10 @@ private async Task CopyChatAsync(string? chatPath)
}
var sourceChat = isCopyOfOpenChat ? openChat! : chat;
- var copy = await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat);
+ var copy = await this.CopyChatAsync(sourceChat);
if (copy is null)
return;
- await this.LoadTreeItemsAsync(startPrefetch: false);
this.CurrentChatThread = copy;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
}
From 5d83e50c72e076ca1a169ec570bdc67a4e78eaa0 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:24:14 +0200
Subject: [PATCH 07/10] Clarified the two ways of copying a chat
---
app/MindWork AI Studio/Components/Workspaces.razor | 2 +-
.../Components/Workspaces.razor.cs | 12 +++++++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor
index 4ea4b401b..d34e2fad9 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor
+++ b/app/MindWork AI Studio/Components/Workspaces.razor
@@ -82,7 +82,7 @@ else
-
+
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs
index 240f7dfbf..98887cc6b 100644
--- a/app/MindWork AI Studio/Components/Workspaces.razor.cs
+++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs
@@ -799,7 +799,17 @@ private async Task RenameChatAsync(string? chatPath)
await this.LoadTreeItemsAsync(startPrefetch: false);
}
- private async Task CopyChatAsync(string? chatPath)
+ ///
+ /// Copies the chat behind the copy button of a tree item and opens the copy.
+ ///
+ /// The directory of the chat to copy.
+ ///
+ /// The copy itself is done by CopyChatAsync, which the chat toolbar uses as well. What this
+ /// handler adds is what only the tree needs: it finds the chat by its directory, and because it
+ /// opens the copy afterward, it asks first when the chat open right now has unsaved changes.
+ /// The chat toolbar asks nothing, since it copies the chat on the screen and keeps working in it.
+ ///
+ private async Task CopyChatFromTreeAsync(string? chatPath)
{
var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null)
From 1fa2f7f2b84ef26f6a9250e8860fac8f1a5b63a1 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:25:45 +0200
Subject: [PATCH 08/10] Updated changelog
---
app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
index b5c8f37e2..82d42678b 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -1,7 +1,7 @@
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
-- Added a way to copy an entire chat. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was.
+- Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature.
- Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages.
-- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
+- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
- Added tools to the assistants. Each assistant has its own tool settings: which tools it starts with and whether you get to change them while you work. The chat, the coding assistant, and the Slide Builder always show the selection; for every other assistant you switch it on where you want it.
From ab831e447ec26c7c3b0aed76d6a5945d16712089 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:37:48 +0200
Subject: [PATCH 09/10] Updated the translations
---
.../Assistants/I18N/allTexts.lua | 3 +++
.../plugin.lua | 25 +++++++++++++++----
.../plugin.lua | 19 ++++++++++++--
3 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index a2d726f0b..88c36251d 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -3622,6 +3622,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic"
-- The media transcription was canceled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "The media transcription was canceled."
+-- Copy this chat & continue in the copy.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Copy this chat & continue in the copy."
+
-- Profile usage is disabled according to your chat template settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile usage is disabled according to your chat template settings."
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 791f63666..7fb62ad52 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
@@ -3624,8 +3624,11 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Kursiv"
-- The media transcription was canceled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "Die Transkription der Mediendatei wurde abgebrochen."
+-- Copy this chat & continue in the copy.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Diesen Chat kopieren & in der Kopie fortfahren."
+
-- Profile usage is disabled according to your chat template settings.
-UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profilnutzung ist gemäß den Einstellungen ihrer Chat-Vorlage deaktiviert."
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profilnutzung ist gemäß den Einstellungen Ihrer Chat-Vorlage deaktiviert."
-- The selected provider is not allowed in this chat due to data security or confidence-level requirements.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2672162875"] = "Der ausgewählte Anbieter ist in diesem Chat aufgrund der Datensicherheit oder der Anforderungen an das Vertrauensniveau nicht zulässig."
@@ -5311,7 +5314,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "Während d
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebnis der Transkription ist leer."
-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with.
-UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Möchten Sie diesen Chat kopieren? Ihre ungespeicherten Änderungen wandern in die Kopie, und der ursprüngliche Chat behält den zuletzt gespeicherten Stand."
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Möchten Sie diesen Chat kopieren? Ihre ungespeicherten Änderungen werden in die Kopie übernommen; der ursprüngliche Chat behält den zuletzt gespeicherten Stand."
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben"
@@ -5407,7 +5410,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Arbeitsberei
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."
-- Chat Name
-UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Name des Chat"
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chatname"
-- Empty chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4019509364"] = "Leerer Chat"
@@ -12975,20 +12978,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] =
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Möchten Sie den Chat „{0}“ im Arbeitsbereich „{1}“ wirklich löschen?"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Chat kopieren"
+
-- Unnamed workspace
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannter Arbeitsbereich"
+-- Copy
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Kopieren"
+
-- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen"
+-- Please enter a chat name.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Bitte geben Sie einen Chatnamen ein."
+
-- Are you sure you want to delete the temporary chat '{0}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchten Sie den temporären Chat „{0}“ wirklich löschen?"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat"
+-- Please enter a name for the copy of your chat '{0}':
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Bitte geben Sie einen Namen für die Kopie Ihres Chats „{0}“ ein:"
+
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Kopie von {0}"
--- Empty chat
-UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Leerer Chat"
+-- Chat Name
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::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 78da4e76d..de64a8e95 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
@@ -3624,6 +3624,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic"
-- The media transcription was canceled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "The media transcription was canceled."
+-- Copy this chat & continue in the copy.
+UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Copy this chat & continue in the copy."
+
-- Profile usage is disabled according to your chat template settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile usage is disabled according to your chat template settings."
@@ -12975,20 +12978,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] =
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?"
+-- Copy Chat
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Copy Chat"
+
-- Unnamed workspace
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed workspace"
+-- Copy
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Copy"
+
-- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"
+-- Please enter a chat name.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Please enter a chat name."
+
-- Are you sure you want to delete the temporary chat '{0}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you sure you want to delete the temporary chat '{0}'?"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
+-- Please enter a name for the copy of your chat '{0}':
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Please enter a name for the copy of your chat '{0}':"
+
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
--- Empty chat
-UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"
+-- Chat Name
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3891063690"] = "Chat Name"
From 4e05781065161a18fe82c1f5db9f8f0044529a80 Mon Sep 17 00:00:00 2001
From: Thorsten Sommer
Date: Wed, 23 Sep 2026 10:43:57 +0200
Subject: [PATCH 10/10] Updated the localization rules for agents
---
AGENTS.md | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index a143c86d7..29a4bbab1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -308,12 +308,28 @@ Multi-level confidence scheme allows users to control which providers see which
6. GitHub Actions builds release binaries for all platforms
7. Binaries uploaded to GitHub Releases
+## Localization
+
+The app's texts are localized in two steps, and the developer always does the first one.
+
+1. The developer starts the app, which runs the I18N collector, and runs the localization assistant
+ in the app for German and US English. Agents never write these initial translations themselves:
+ they neither add nor regenerate entries in `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`,
+ `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`,
+ or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`.
+ When new or changed texts are waiting for translation, remind the developer to start the app and
+ run the localization.
+2. Afterward, agents always review the German translation. Compare the new and changed values of the
+ de-de `plugin.lua` with `main`, check them against the wording already established there, and
+ correct or improve them directly in that file. `allTexts.lua` and the en-us `plugin.lua` stay as
+ the assistant wrote them.
+
## Important Development Notes
- **File changes require Write/Edit tools** - Never use bash commands like `cat <`
- **End of file formatting** - Do not append an extra empty line at the end of files.
- **No automated formatting for Rust or .NET files** - Never run automated formatters on Rust files (`.rs`) or .NET files (`.cs`, `.razor`, `.csproj`, etc.). Only make the minimal manual formatting changes required for the specific edit.
-- **I18N resources are generated** - Do not manually edit `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. These files are updated automatically by the I18N process.
+- **I18N resources are generated** - The developer produces the translations by running the localization assistant in the app; agents only review and correct the German values afterward. See "Localization" above.
- **Spaces in paths** - Always quote paths with spaces in bash commands
- **Agent-run builds** - Never start `.NET` or Rust builds in the agent's own shell; it is sandboxed. Use the `rider` and `rustrover` MCP servers instead, which build in the IDE outside that sandbox. See "Running builds from an agent" above.
- **Debug environment** - Reads `startup.env` file with IPC credentials