feat(worker): remove a queued interactive message

StreamingClaudeSession.RemoveQueuedAsync drops the first occurrence of a queued
message from _pending and re-broadcasts the updated queue. Wired through
InteractiveSessionService + WorkerHub.RemoveQueuedInteractiveMessage +
IWorkerClient.RemoveQueuedInteractiveMessageAsync. Removal by text (first match)
is robust to a turn flushing mid-click. Fakes + ILiveSession impls updated.
This commit is contained in:
Mika Kuns
2026-06-26 11:15:10 +02:00
parent e7fa373a74
commit fd1e38fb7f
11 changed files with 119 additions and 0 deletions

View File

@@ -52,6 +52,7 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Answer a question a running task raised via AskUser.</summary>
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
Task SendInteractiveMessageAsync(string taskId, string text);
Task RemoveQueuedInteractiveMessageAsync(string taskId, string text);
Task StopInteractiveSessionAsync(string taskId);
Task InterruptInteractiveSessionAsync(string taskId);
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>

View File

@@ -309,6 +309,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
catch { /* offline or session already ended */ }
}
public async Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
{
try { await _hub.InvokeAsync("RemoveQueuedInteractiveMessage", taskId, text); }
catch { /* offline or session already ended */ }
}
public async Task StopInteractiveSessionAsync(string taskId)
{
try { await _hub.InvokeAsync("StopInteractiveSession", taskId); }

View File

@@ -584,6 +584,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public Task InterruptInteractiveSession(string taskId) =>
_interactive.InterruptAsync(taskId, Context.ConnectionAborted);
public Task RemoveQueuedInteractiveMessage(string taskId, string text) =>
_interactive.RemoveQueuedAsync(taskId, text, Context.ConnectionAborted);
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);

View File

@@ -127,6 +127,12 @@ public sealed class InteractiveSessionService
await session.SendUserMessageAsync(text, ct);
}
public async Task RemoveQueuedAsync(string taskId, string text, CancellationToken ct)
{
if (_registry.TryGet(taskId, out var session))
await session.RemoveQueuedAsync(text, ct);
}
public async Task InterruptAsync(string taskId, CancellationToken ct)
{
if (_registry.TryGet(taskId, out var session))

View File

@@ -4,6 +4,7 @@ public interface ILiveSession : IAsyncDisposable
{
bool IsTurnInFlight { get; }
Task SendUserMessageAsync(string text, CancellationToken ct);
Task RemoveQueuedAsync(string text, CancellationToken ct);
Task InterruptAsync(CancellationToken ct);
Task StopAsync();
}

View File

@@ -118,6 +118,35 @@ public sealed class StreamingClaudeSession : ILiveSession
_onUserMessageSent?.Invoke(text);
}
public async Task RemoveQueuedAsync(string text, CancellationToken ct)
{
IReadOnlyList<string>? snapshot = null;
await _sendLock.WaitAsync(ct);
try
{
if (_pending.Count == 0) return;
var list = _pending.ToList();
var idx = list.IndexOf(text);
if (idx < 0) return;
list.RemoveAt(idx);
_pending.Clear();
foreach (var item in list)
_pending.Enqueue(item);
snapshot = SnapshotPending();
}
finally
{
_sendLock.Release();
}
if (snapshot is not null)
_onQueueChanged?.Invoke(snapshot);
}
public async Task InterruptAsync(CancellationToken ct)
{
await _sendLock.WaitAsync(ct);