Fixed the chat user prompt being cleared when toggling the workspace view

This commit is contained in:
Thorsten Sommer 2026-05-25 20:43:43 +02:00
parent 7bf1c4ae25
commit 9a947579cc
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
6 changed files with 124 additions and 43 deletions

View File

@ -37,7 +37,7 @@
<MudTextField <MudTextField
T="string" T="string"
@ref="@this.inputField" @ref="@this.inputField"
@bind-Text="@this.userInput" @bind-Text="@this.UserInput"
Variant="Variant.Outlined" Variant="Variant.Outlined"
AutoGrow="@true" AutoGrow="@true"
Lines="3" Lines="3"
@ -96,28 +96,28 @@
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
{ {
<MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@(() => this.MoveChatToWorkspace())"/> <MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@this.MoveChatToWorkspace"/>
</MudTooltip> </MudTooltip>
} }
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" @bind-DocumentPaths="@this.chatDocumentPaths" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider"/> <AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<MudTooltip Text="@T("Bold")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Bold")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatBold" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_BOLD)" Disabled="@this.IsInputForbidden()"/> <MudIconButton Icon="@Icons.Material.Filled.FormatBold" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_BOLD))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Italic")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Italic")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatItalic" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_ITALIC)" Disabled="@this.IsInputForbidden()"/> <MudIconButton Icon="@Icons.Material.Filled.FormatItalic" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_ITALIC))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Heading")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Heading")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.TextFields" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_HEADING)" Disabled="@this.IsInputForbidden()"/> <MudIconButton Icon="@Icons.Material.Filled.TextFields" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_HEADING))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Bulleted List")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Bulleted List")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatListBulleted" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_BULLET_LIST)" Disabled="@this.IsInputForbidden()"/> <MudIconButton Icon="@Icons.Material.Filled.FormatListBulleted" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_BULLET_LIST))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Code")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Code")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Code" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_CODE)" Disabled="@this.IsInputForbidden()"/> <MudIconButton Icon="@Icons.Material.Filled.Code" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_CODE))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip> </MudTooltip>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/> <MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
@ -137,7 +137,7 @@
@if (this.IsCurrentChatStreaming) @if (this.IsCurrentChatStreaming)
{ {
<MudTooltip Text="@T("Stop generation")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Stop generation")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(() => this.CancelStreaming())"/> <MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@this.CancelStreaming"/>
</MudTooltip> </MudTooltip>
} }

View File

@ -38,6 +38,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
[Parameter] [Parameter]
public Workspaces? Workspaces { get; set; } public Workspaces? Workspaces { get; set; }
[Parameter]
public ChatComposerState ComposerState { get; set; } = new();
[Inject] [Inject]
private ILogger<ChatComponent> Logger { get; set; } = null!; private ILogger<ChatComponent> Logger { get; set; } = null!;
@ -62,7 +65,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool mustScrollToBottomAfterRender; private bool mustScrollToBottomAfterRender;
private InnerScrolling scrollingArea = null!; private InnerScrolling scrollingArea = null!;
private byte scrollRenderCountdown; private byte scrollRenderCountdown;
private string userInput = string.Empty;
private bool mustStoreChat; private bool mustStoreChat;
private bool mustLoadChat; private bool mustLoadChat;
private LoadChat loadChat; private LoadChat loadChat;
@ -74,12 +76,26 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private Guid loadedParameterWorkspaceId = Guid.Empty; private Guid loadedParameterWorkspaceId = Guid.Empty;
private Guid foregroundChatId = Guid.Empty; private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion; private int workspaceHeaderSyncVersion;
private HashSet<FileAttachment> chatDocumentPaths = [];
// Unfortunately, we need the input field reference to blur the focus away. Without // Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field. // this, we cannot clear the input field.
private MudTextField<string> inputField = null!; private MudTextField<string> inputField = null!;
/// <summary>
/// Represents the user's input in the chat interface.
/// </summary>
/// <remarks>
/// This property serves as a bridge between the chat component and the
/// underlying composer state, allowing user input to be dynamically updated
/// and managed. The setter also triggers state changes within the composer
/// to track whether the user has drafted any input.
/// </remarks>
private string UserInput
{
get => this.ComposerState.UserInput;
set => this.ComposerState.SetUserInput(value);
}
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@ -96,15 +112,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Get the preselected chat template: // Get the preselected chat template:
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT); this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
this.userInput = this.currentChatTemplate.PredefinedUserPrompt; if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent)
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).FirstOrDefault(); var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(deferredInput)) if (!string.IsNullOrWhiteSpace(deferredInput))
this.userInput = deferredInput; this.ComposerState.SetUserInput(deferredInput);
// Apply template's file attachments, if any:
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
// //
// Check for deferred messages of the kind 'SEND_TO_CHAT', // Check for deferred messages of the kind 'SEND_TO_CHAT',
@ -450,12 +463,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{ {
this.currentChatTemplate = chatTemplate; this.currentChatTemplate = chatTemplate;
if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt)) if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt))
this.userInput = this.currentChatTemplate.PredefinedUserPrompt; this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt);
// Apply template's file attachments (replaces existing): // Apply template's file attachments (replaces existing):
this.chatDocumentPaths.Clear(); this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
if(this.ChatThread is null) if(this.ChatThread is null)
return; return;
@ -515,6 +526,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.dataSourceSelectionComponent.Hide(); this.dataSourceSelectionComponent.Hide();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.ComposerState.MarkUserDraft();
var key = keyEvent.Code.ToLowerInvariant(); var key = keyEvent.Code.ToLowerInvariant();
// Was the enter key (either enter or numpad enter) pressed? // Was the enter key (either enter or numpad enter) pressed?
@ -546,7 +558,16 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(this.dataSourceSelectionComponent?.IsVisible ?? false) if(this.dataSourceSelectionComponent?.IsVisible ?? false)
this.dataSourceSelectionComponent.Hide(); this.dataSourceSelectionComponent.Hide();
this.userInput = await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType); this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
this.hasUnsavedChanges = true;
}
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
} }
@ -574,7 +595,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
WorkspaceId = this.currentWorkspaceId, WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(), ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions, DataSourceOptions = this.earlyDataSourceOptions,
Name = this.ExtractThreadName(this.userInput), Name = this.ExtractThreadName(this.ComposerState.UserInput),
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(), Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
}; };
@ -585,7 +606,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{ {
// Set the thread name if it is empty: // Set the thread name if it is empty:
if (string.IsNullOrWhiteSpace(this.ChatThread.Name)) if (string.IsNullOrWhiteSpace(this.ChatThread.Name))
this.ChatThread.Name = this.ExtractThreadName(this.userInput); this.ChatThread.Name = this.ExtractThreadName(this.ComposerState.UserInput);
// Update provider, profile and chat template: // Update provider, profile and chat template:
this.ChatThread.SelectedProvider = this.Provider.Id; this.ChatThread.SelectedProvider = this.Provider.Id;
@ -602,14 +623,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
IContent? lastUserPrompt; IContent? lastUserPrompt;
if (!reuseLastUserPrompt) if (!reuseLastUserPrompt)
{ {
var normalizedAttachments = this.chatDocumentPaths var normalizedAttachments = this.ComposerState.FileAttachments
.Select(attachment => attachment.Normalize()) .Select(attachment => attachment.Normalize())
.Where(attachment => attachment.IsValid) .Where(attachment => attachment.IsValid)
.ToList(); .ToList();
lastUserPrompt = new ContentText lastUserPrompt = new ContentText
{ {
Text = this.userInput, Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments, FileAttachments = normalizedAttachments,
}; };
@ -656,8 +677,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Clear the input field: // Clear the input field:
await this.inputField.FocusAsync(); await this.inputField.FocusAsync();
this.userInput = string.Empty; this.ComposerState.Clear();
this.chatDocumentPaths.Clear();
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
@ -751,7 +771,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Reset our state: // Reset our state:
// //
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.ComposerState.Clear();
// //
// Reset the LLM provider considering the user's settings: // Reset the LLM provider considering the user's settings:
@ -808,12 +828,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
}; };
} }
this.userInput = this.currentChatTemplate.PredefinedUserPrompt; this.ComposerState.ApplyTemplate(this.currentChatTemplate);
// Apply template's file attachments:
this.chatDocumentPaths.Clear();
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
// Now, we have to reset the data source options as well: // Now, we have to reset the data source options as well:
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
@ -871,7 +886,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private async Task LoadedChatChanged(bool notifyParent = true) private async Task LoadedChatChanged(bool notifyParent = true)
{ {
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.ComposerState.Clear();
if (this.ChatThread is not null) if (this.ChatThread is not null)
{ {
@ -907,7 +922,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private async Task ResetState() private async Task ResetState()
{ {
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.ComposerState.Clear();
this.ClearWorkspaceHeaderState(); this.ClearWorkspaceHeaderState();
this.ChatThread = null; this.ChatThread = null;
@ -1010,11 +1025,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private void RestoreComposerFromTextBlock(ContentText textBlock) private void RestoreComposerFromTextBlock(ContentText textBlock)
{ {
this.userInput = textBlock.Text; this.ComposerState.RestoreFromTextBlock(textBlock);
this.chatDocumentPaths.Clear();
foreach (var attachment in textBlock.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
} }
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
@ -1062,7 +1073,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.IsCurrentChatStreaming) if (this.IsCurrentChatStreaming)
return Task.FromResult((TResult?) (object) false); return Task.FromResult((TResult?) (object) false);
return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft));
} }
return Task.FromResult(default(TResult)); return Task.FromResult(default(TResult));

View File

@ -0,0 +1,65 @@
using AIStudio.Chat;
using AIStudio.Settings;
namespace AIStudio.Components;
public sealed class ChatComposerState
{
public string UserInput { get; private set; } = string.Empty;
public HashSet<FileAttachment> FileAttachments { get; } = [];
public bool HasUserDraft { get; private set; }
public bool HasComposerContent => !string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0;
public bool HasVisibleUserDraft => this.HasUserDraft && (!string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0);
public void ApplyTemplate(ChatTemplate chatTemplate)
{
this.UserInput = chatTemplate.PredefinedUserPrompt;
this.FileAttachments.Clear();
foreach (var attachment in chatTemplate.FileAttachments)
this.FileAttachments.Add(attachment.Normalize());
this.HasUserDraft = false;
}
public void SetUserInput(string? userInput)
{
this.UserInput = userInput ?? string.Empty;
this.HasUserDraft = !string.IsNullOrWhiteSpace(userInput);
}
public void SetSystemInput(string? userInput)
{
this.UserInput = userInput ?? string.Empty;
this.HasUserDraft = false;
}
public void MarkUserDraft()
{
this.HasUserDraft = true;
}
public void ReplaceFileAttachments(IEnumerable<FileAttachment> fileAttachments)
{
this.FileAttachments.Clear();
foreach (var attachment in fileAttachments)
this.FileAttachments.Add(attachment.Normalize());
}
public void Clear()
{
this.UserInput = string.Empty;
this.FileAttachments.Clear();
this.HasUserDraft = false;
}
public void RestoreFromTextBlock(ContentText textBlock)
{
this.UserInput = textBlock.Text;
this.ReplaceFileAttachments(textBlock.FileAttachments);
this.HasUserDraft = true;
}
}

View File

@ -89,6 +89,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
</EndContent> </EndContent>
@ -115,6 +116,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
</MudStack> </MudStack>
@ -125,6 +127,7 @@
<ChatComponent <ChatComponent
@bind-ChatThread="@this.chatThread" @bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings" @bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces" Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/> WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
} }

View File

@ -26,6 +26,7 @@ public partial class Chat : MSGComponentBase
private string currentWorkspaceName = string.Empty; private string currentWorkspaceName = string.Empty;
private Workspaces? workspaces; private Workspaces? workspaces;
private double splitterPosition = 30; private double splitterPosition = 30;
private readonly ChatComposerState composerState = new();
private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6)); private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6));

View File

@ -17,6 +17,7 @@
- Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel. - Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel.
- Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading. - Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading.
- Fixed missing translations for file type names in file selection dialogs. - Fixed missing translations for file type names in file selection dialogs.
- Fixed the chat user prompt being cleared when toggling the workspace view.
- Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system. - Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system.
- Upgraded Rust to v1.95.0. - Upgraded Rust to v1.95.0.
- Upgraded .NET to v9.0.16. - Upgraded .NET to v9.0.16.