feat(ui): remove a queued interactive message with a ✕

Queued rows are now QueuedMessageViewModel (Text + RemoveCommand); each shows a
✕ (Icon.WinClose) that calls RemoveQueuedInteractiveMessageAsync(taskId, text).
The worker re-broadcasts the queue, rebuilding the strip without the removed
message. Adds session.composer.unqueue (en/de).
This commit is contained in:
Mika Kuns
2026-06-26 11:19:47 +02:00
parent fd1e38fb7f
commit afe7218b7c
6 changed files with 80 additions and 18 deletions

View File

@@ -237,7 +237,8 @@
"send": "Senden", "send": "Senden",
"stop": "Sitzung beenden", "stop": "Sitzung beenden",
"interrupt": "Aktuellen Zug unterbrechen", "interrupt": "Aktuellen Zug unterbrechen",
"queued": "Wartet — wird nach dem aktuellen Zug gesendet" "queued": "Wartet — wird nach dem aktuellen Zug gesendet",
"unqueue": "Aus Warteschlange entfernen"
} }
}, },
"missionControl": { "missionControl": {

View File

@@ -237,7 +237,8 @@
"send": "Send", "send": "Send",
"stop": "Stop session", "stop": "Stop session",
"interrupt": "Interrupt current turn", "interrupt": "Interrupt current turn",
"queued": "Queued — sends after the current turn" "queued": "Queued — sends after the current turn",
"unqueue": "Remove from queue"
} }
}, },
"missionControl": { "missionControl": {

View File

@@ -70,7 +70,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private const string RoadblockMarker = "Roadblocks reported during the run:"; private const string RoadblockMarker = "Roadblocks reported during the run:";
public ObservableCollection<string> QueuedMessages { get; } = new(); public ObservableCollection<QueuedMessageViewModel> QueuedMessages { get; } = new();
public bool HasQueuedMessages => QueuedMessages.Count > 0; public bool HasQueuedMessages => QueuedMessages.Count > 0;
// Captured handler delegates for disposal // Captured handler delegates for disposal
@@ -181,7 +181,15 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
{ {
if (taskId != _subscribedTaskId) return; if (taskId != _subscribedTaskId) return;
QueuedMessages.Clear(); QueuedMessages.Clear();
foreach (var m in pending) QueuedMessages.Add(m); foreach (var m in pending)
{
var text = m;
QueuedMessages.Add(new QueuedMessageViewModel
{
Text = text,
RemoveCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => _ = RemoveQueuedAsync(text)),
});
}
OnPropertyChanged(nameof(HasQueuedMessages)); OnPropertyChanged(nameof(HasQueuedMessages));
}; };
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged; _worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
@@ -234,6 +242,12 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId); await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
} }
private async System.Threading.Tasks.Task RemoveQueuedAsync(string text)
{
if (!string.IsNullOrEmpty(_subscribedTaskId))
await _worker.RemoveQueuedInteractiveMessageAsync(_subscribedTaskId, text);
}
private void ClearPendingQuestion() private void ClearPendingQuestion()
{ {
PendingQuestionId = null; PendingQuestionId = null;
@@ -515,3 +529,9 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent; _worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
} }
} }
public sealed class QueuedMessageViewModel
{
public required string Text { get; init; }
public required System.Windows.Input.ICommand RemoveCommand { get; init; }
}

View File

@@ -276,19 +276,30 @@
Foreground="{DynamicResource TextMuteBrush}" /> Foreground="{DynamicResource TextMuteBrush}" />
<ItemsControl ItemsSource="{Binding Monitor.QueuedMessages}"> <ItemsControl ItemsSource="{Binding Monitor.QueuedMessages}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="vm:QueuedMessageViewModel">
<StackPanel Orientation="Horizontal" Spacing="6"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
<TextBlock Text="" <TextBlock Grid.Column="0"
Text="⧗"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
VerticalAlignment="Center" /> VerticalAlignment="Center"
<TextBlock Text="{Binding}" Margin="0,0,6,0" />
<TextBlock Grid.Column="1"
Text="{Binding Text}"
Foreground="{DynamicResource TextDimBrush}" Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis"
</StackPanel> VerticalAlignment="Center" />
<Button Grid.Column="2"
Classes="title-ctrl"
Command="{Binding RemoveCommand}"
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
Margin="4,0,0,0">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
</Button>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>

View File

@@ -64,19 +64,30 @@
Foreground="{DynamicResource TextMuteBrush}" /> Foreground="{DynamicResource TextMuteBrush}" />
<ItemsControl ItemsSource="{Binding #Root.QueuedMessages}"> <ItemsControl ItemsSource="{Binding #Root.QueuedMessages}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate x:DataType="vm:QueuedMessageViewModel">
<StackPanel Orientation="Horizontal" Spacing="6"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
<TextBlock Text="" <TextBlock Grid.Column="0"
Text="⧗"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
VerticalAlignment="Center" /> VerticalAlignment="Center"
<TextBlock Text="{Binding}" Margin="0,0,6,0" />
<TextBlock Grid.Column="1"
Text="{Binding Text}"
Foreground="{DynamicResource TextDimBrush}" Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis"
</StackPanel> VerticalAlignment="Center" />
<Button Grid.Column="2"
Classes="title-ctrl"
Command="{Binding RemoveCommand}"
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
Margin="4,0,0,0">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
</Button>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>

View File

@@ -333,6 +333,8 @@ public class TaskMonitorViewModelTests : IDisposable
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1", "msg2" }); worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1", "msg2" });
Assert.Equal(2, vm.QueuedMessages.Count); Assert.Equal(2, vm.QueuedMessages.Count);
Assert.Equal("msg1", vm.QueuedMessages[0].Text);
Assert.Equal("msg2", vm.QueuedMessages[1].Text);
Assert.True(vm.HasQueuedMessages); Assert.True(vm.HasQueuedMessages);
} }
@@ -378,6 +380,22 @@ public class TaskMonitorViewModelTests : IDisposable
Assert.False(vm.HasQueuedMessages); Assert.False(vm.HasQueuedMessages);
} }
[Fact]
public async Task QueuedMessageViewModel_RemoveCommand_RecordsRemoveCall()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "a", "b" });
vm.QueuedMessages[0].RemoveCommand.Execute(null);
// RemoveQueuedAsync is fire-and-forget; yield to let the async continuation run
await System.Threading.Tasks.Task.Yield();
Assert.Single(worker.RemovedQueued);
Assert.Equal(("t1", "a"), worker.RemovedQueued[0]);
}
[Fact] [Fact]
public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall() public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall()
{ {