Fix the lifecycle of the debounced user prompt component

This commit is contained in:
Thorsten Sommer committed 2026-09-06 11:49:43 +02:00
1 parent 8950acb654
commit 8cdfdc4895
2 files changed
+61 -16

No files matched your search

@@ -93,7 +93,6 @@ public partial class ChatComponent : MSGComponentBase
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 = [];
private string tokenCount = "0"; private string tokenCount = "0";
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath); private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
private string TokenCountMessage => this.HasCustomTokenizer private string TokenCountMessage => this.HasCustomTokenizer
@@ -394,7 +393,7 @@ public partial class ChatComponent : MSGComponentBase
await this.ApplyLoadedChatParameterAsync(); await this.ApplyLoadedChatParameterAsync();
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
if (providerChanged && this.HasCustomTokenizer && this.inputField is not null) if (providerChanged && this.HasCustomTokenizer)
await this.CalculateTokenCount(); await this.CalculateTokenCount();
await this.ConsumeMediaOutcomeAsync(); await this.ConsumeMediaOutcomeAsync();
@@ -1323,13 +1322,19 @@ public partial class ChatComponent : MSGComponentBase
return; return;
} }
if (this.inputField.Value is null) //
// Read the text from the bound property rather than from the input field: the field is a
// component reference, which is only set once the component has rendered. Counting is also
// triggered while parameters are set, which happens before that.
//
var currentInput = this.UserInput;
if (string.IsNullOrEmpty(currentInput))
{ {
this.tokenCount = "0"; this.tokenCount = "0";
return; return;
} }
var response = await this.RustService.GetTokenCount(this.Provider, this.inputField.Value); var response = await this.RustService.GetTokenCount(this.Provider, currentInput);
if (response is null) if (response is null)
return; return;
if (!response.Value.Success) if (!response.Value.Success)
@@ -8,7 +8,7 @@ namespace AIStudio.Components;
/// Keeps the base API while adding a debounce timer. /// Keeps the base API while adding a debounce timer.
/// Callers can override any property as usual. /// Callers can override any property as usual.
/// </summary> /// </summary>
public class UserPromptComponent<T> : MudTextField<T> public class UserPromptComponent<T> : MudTextField<T>, IDisposable
{ {
[Parameter] [Parameter]
public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800); public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800);
@@ -21,6 +21,7 @@ public class UserPromptComponent<T> : MudTextField<T>
private string lastParameterText = string.Empty; private string lastParameterText = string.Empty;
private string lastNotifiedText = string.Empty; private string lastNotifiedText = string.Empty;
private bool isInitialized; private bool isInitialized;
private bool isDisposed;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -29,16 +30,7 @@ public class UserPromptComponent<T> : MudTextField<T>
this.lastNotifiedText = this.text; this.lastNotifiedText = this.text;
this.debounceTimer.AutoReset = false; this.debounceTimer.AutoReset = false;
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds; this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
this.debounceTimer.Elapsed += (_, _) => this.debounceTimer.Elapsed += this.WhenDebounceElapsed;
{
this.debounceTimer.Stop();
if (this.text == this.lastNotifiedText)
return;
this.lastNotifiedText = this.text;
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text));
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text));
};
this.isInitialized = true; this.isInitialized = true;
await base.OnInitializedAsync(); await base.OnInitializedAsync();
@@ -47,8 +39,11 @@ public class UserPromptComponent<T> : MudTextField<T>
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Ensure the timer uses the latest debouncing interval: // Ensure the timer uses the latest debouncing interval:
if (!this.isInitialized) if (!this.isInitialized || this.isDisposed)
{
await base.OnParametersSetAsync();
return; return;
}
if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1) if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1)
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds; this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
@@ -65,4 +60,49 @@ public class UserPromptComponent<T> : MudTextField<T>
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
} }
private void WhenDebounceElapsed(object? sender, System.Timers.ElapsedEventArgs args)
{
this.debounceTimer.Stop();
//
// The timer runs on its own thread and may still fire while this component is being torn
// down. Notifying a renderer which is already gone would throw on that thread, where no
// caller is left to handle it.
//
if (this.isDisposed || this.text == this.lastNotifiedText)
return;
this.lastNotifiedText = this.text;
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: notifying about changed text");
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: handling changed text asynchronously");
}
#region IDisposable
public void Dispose()
{
if (this.isDisposed)
return;
//
// Set before stopping the timer: the handler might be running on the timer thread right
// now, and this is what tells it to leave the gone renderer alone.
//
this.isDisposed = true;
try
{
this.debounceTimer.Elapsed -= this.WhenDebounceElapsed;
this.debounceTimer.Stop();
this.debounceTimer.Dispose();
}
catch
{
// ignore
}
GC.SuppressFinalize(this);
}
#endregion
} }