feat(ui): show queued interactive messages above the composer

A queued message now appears in a pending strip above the input box (driven by
InteractiveQueueChanged), not optimistically in the transcript. The transcript
user line is added on delivery via InteractiveMessageSent. SessionTerminalView
gains QueuedMessages/HasQueuedMessages styled props (Mission Control); WorkConsole
binds Monitor.* (task detail). Adds session.composer.queued (en/de).
This commit is contained in:
Mika Kuns
2026-06-26 11:04:25 +02:00
parent 84034e8395
commit 7c9ff18ced
8 changed files with 250 additions and 70 deletions

View File

@@ -236,7 +236,8 @@
"placeholder": "Nachricht an die Sitzung…", "placeholder": "Nachricht an die Sitzung…",
"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"
} }
}, },
"missionControl": { "missionControl": {

View File

@@ -236,7 +236,8 @@
"placeholder": "Message the session…", "placeholder": "Message the session…",
"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"
} }
}, },
"missionControl": { "missionControl": {

View File

@@ -70,6 +70,9 @@ 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 bool HasQueuedMessages => QueuedMessages.Count > 0;
// Captured handler delegates for disposal // Captured handler delegates for disposal
private readonly Action<string, string> _onTaskMessage; private readonly Action<string, string> _onTaskMessage;
private readonly Action<string, string, DateTime> _onTaskStarted; private readonly Action<string, string, DateTime> _onTaskStarted;
@@ -79,6 +82,8 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private readonly Action<string, string> _onTaskQuestionResolved; private readonly Action<string, string> _onTaskQuestionResolved;
private readonly Action<string> _onInteractiveStarted; private readonly Action<string> _onInteractiveStarted;
private readonly Action<string> _onInteractiveEnded; private readonly Action<string> _onInteractiveEnded;
private readonly Action<string, IReadOnlyList<string>> _onInteractiveQueueChanged;
private readonly Action<string, string> _onInteractiveMessageSent;
// Interactive composer — active while the worker is in an interactive session. // Interactive composer — active while the worker is in an interactive session.
[ObservableProperty] [ObservableProperty]
@@ -164,9 +169,29 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
_onInteractiveEnded = taskId => _onInteractiveEnded = taskId =>
{ {
if (taskId == _subscribedTaskId) { IsInteractiveLive = false; AgentState = "done"; } if (taskId != _subscribedTaskId) return;
IsInteractiveLive = false;
AgentState = "done";
QueuedMessages.Clear();
OnPropertyChanged(nameof(HasQueuedMessages));
}; };
_worker.InteractiveSessionEndedEvent += _onInteractiveEnded; _worker.InteractiveSessionEndedEvent += _onInteractiveEnded;
_onInteractiveQueueChanged = (taskId, pending) =>
{
if (taskId != _subscribedTaskId) return;
QueuedMessages.Clear();
foreach (var m in pending) QueuedMessages.Add(m);
OnPropertyChanged(nameof(HasQueuedMessages));
};
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
_onInteractiveMessageSent = (taskId, text) =>
{
if (taskId == _subscribedTaskId)
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
};
_worker.InteractiveMessageSentEvent += _onInteractiveMessageSent;
} }
// Surface a pending question (used by live event + re-attach hydration). // Surface a pending question (used by live event + re-attach hydration).
@@ -189,7 +214,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
if (string.IsNullOrEmpty(_subscribedTaskId)) return; if (string.IsNullOrEmpty(_subscribedTaskId)) return;
var text = ComposerDraft; var text = ComposerDraft;
if (string.IsNullOrWhiteSpace(text)) return; if (string.IsNullOrWhiteSpace(text)) return;
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
ComposerDraft = string.Empty; ComposerDraft = string.Empty;
await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text); await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text);
} }
@@ -260,6 +284,8 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
ClearPendingQuestion(); ClearPendingQuestion();
IsInteractiveLive = false; IsInteractiveLive = false;
ComposerDraft = string.Empty; ComposerDraft = string.Empty;
QueuedMessages.Clear();
OnPropertyChanged(nameof(HasQueuedMessages));
} }
[ObservableProperty] [ObservableProperty]
@@ -485,5 +511,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved; _worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted; _worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded; _worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
_worker.InteractiveQueueChangedEvent -= _onInteractiveQueueChanged;
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
} }
} }

View File

@@ -263,10 +263,39 @@
Command="{Binding RejectReviewCommand}" /> Command="{Binding RejectReviewCommand}" />
</Grid> </Grid>
<!-- Interactive composer — chat with a live in-app session; shell-prompt style, <!-- Interactive composer + queued strip — chat with a live in-app session -->
only while an interactive session is running for this task. --> <StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
<Grid DockPanel.Dock="Bottom" <!-- Queued messages strip -->
IsVisible="{Binding Monitor.IsInteractiveLive}" <Border IsVisible="{Binding Monitor.HasQueuedMessages}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="12,4">
<StackPanel Spacing="2">
<TextBlock Classes="meta"
Text="{loc:Tr session.composer.queued}"
Foreground="{DynamicResource TextMuteBrush}" />
<ItemsControl ItemsSource="{Binding Monitor.QueuedMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="⧗"
Foreground="{DynamicResource TextMuteBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding}"
Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Composer input row -->
<Grid IsVisible="{Binding Monitor.IsInteractiveLive}"
ColumnDefinitions="Auto,*,Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto"
Margin="12,2,12,8"> Margin="12,2,12,8">
<TextBlock Grid.Column="0" Text="&#x276F;" <TextBlock Grid.Column="0" Text="&#x276F;"
@@ -298,6 +327,7 @@
VerticalAlignment="Center" Margin="4,0,0,0" VerticalAlignment="Center" Margin="4,0,0,0"
Command="{Binding Monitor.SubmitComposerCommand}" /> Command="{Binding Monitor.SubmitComposerCommand}" />
</Grid> </Grid>
</StackPanel>
<ScrollViewer Name="LogScroll" <ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible"

View File

@@ -50,9 +50,40 @@
</Border> </Border>
</Grid> </Grid>
<!-- ── Composer bar ── --> <!-- ── Queued strip + Composer bar ── -->
<Border DockPanel.Dock="Bottom" <StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
IsVisible="{Binding #Root.IsComposerVisible}" <!-- Queued messages strip -->
<Border IsVisible="{Binding #Root.HasQueuedMessages}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="8,4">
<StackPanel Spacing="2">
<TextBlock Classes="meta"
Text="{loc:Tr session.composer.queued}"
Foreground="{DynamicResource TextMuteBrush}" />
<ItemsControl ItemsSource="{Binding #Root.QueuedMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="⧗"
Foreground="{DynamicResource TextMuteBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding}"
Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Composer input row -->
<Border IsVisible="{Binding #Root.IsComposerVisible}"
Background="{DynamicResource Surface2Brush}" Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0" BorderThickness="0,1,0,0"
@@ -79,6 +110,7 @@
Command="{Binding #Root.SubmitCommand}"/> Command="{Binding #Root.SubmitCommand}"/>
</Grid> </Grid>
</Border> </Border>
</StackPanel>
<!-- ── Log output ── --> <!-- ── Log output ── -->
<ScrollViewer Name="LogScroll" <ScrollViewer Name="LogScroll"

View File

@@ -29,6 +29,10 @@ public partial class SessionTerminalView : UserControl
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(InterruptCommand)); AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(InterruptCommand));
public static readonly StyledProperty<string?> ComposerPlaceholderProperty = public static readonly StyledProperty<string?> ComposerPlaceholderProperty =
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder)); AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder));
public static readonly StyledProperty<System.Collections.IEnumerable?> QueuedMessagesProperty =
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(QueuedMessages));
public static readonly StyledProperty<bool> HasQueuedMessagesProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(HasQueuedMessages), defaultValue: false);
public IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); } public IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); } public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
@@ -40,6 +44,8 @@ public partial class SessionTerminalView : UserControl
public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); } public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); }
public ICommand? InterruptCommand { get => GetValue(InterruptCommandProperty); set => SetValue(InterruptCommandProperty, value); } public ICommand? InterruptCommand { get => GetValue(InterruptCommandProperty); set => SetValue(InterruptCommandProperty, value); }
public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); } public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); }
public System.Collections.IEnumerable? QueuedMessages { get => GetValue(QueuedMessagesProperty); set => SetValue(QueuedMessagesProperty, value); }
public bool HasQueuedMessages { get => GetValue(HasQueuedMessagesProperty); set => SetValue(HasQueuedMessagesProperty, value); }
private INotifyCollectionChanged? _subscribedCollection; private INotifyCollectionChanged? _subscribedCollection;

View File

@@ -112,7 +112,9 @@
ComposerText="{Binding ComposerDraft, Mode=TwoWay}" ComposerText="{Binding ComposerDraft, Mode=TwoWay}"
SubmitCommand="{Binding SubmitComposerCommand}" SubmitCommand="{Binding SubmitComposerCommand}"
InterruptCommand="{Binding InterruptInteractiveCommand}" InterruptCommand="{Binding InterruptInteractiveCommand}"
ComposerPlaceholder="{loc:Tr session.composer.placeholder}" /> ComposerPlaceholder="{loc:Tr session.composer.placeholder}"
QueuedMessages="{Binding QueuedMessages}"
HasQueuedMessages="{Binding HasQueuedMessages}" />
</DockPanel> </DockPanel>
</Border> </Border>

View File

@@ -280,7 +280,7 @@ public class TaskMonitorViewModelTests : IDisposable
} }
[Fact] [Fact]
public async Task SubmitComposer_AddsUserLogLine_ClearsDraft_CallsClient() public async Task SubmitComposer_CallsClient_ClearsDraft_DoesNotAddLogLine()
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = Build(worker); using var vm = Build(worker);
@@ -293,9 +293,89 @@ public class TaskMonitorViewModelTests : IDisposable
Assert.Single(worker.SentInteractive); Assert.Single(worker.SentInteractive);
Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]); Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]);
Assert.Equal(string.Empty, vm.ComposerDraft); Assert.Equal(string.Empty, vm.ComposerDraft);
// Log must NOT be updated by submit itself; it updates on InteractiveMessageSent
Assert.Empty(vm.Log);
}
[Fact]
public void InteractiveMessageSent_ForSubscribedTask_AddsUserLogLine()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveMessageSent("t1", "hello from event");
Assert.Single(vm.Log); Assert.Single(vm.Log);
Assert.Equal(LogKind.User, vm.Log[0].Kind); Assert.Equal(LogKind.User, vm.Log[0].Kind);
Assert.Equal("do the thing", vm.Log[0].Text); Assert.Equal("hello from event", vm.Log[0].Text);
}
[Fact]
public void InteractiveMessageSent_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveMessageSent("other", "not mine");
Assert.Empty(vm.Log);
}
[Fact]
public void InteractiveQueueChanged_ForSubscribedTask_PopulatesQueue()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1", "msg2" });
Assert.Equal(2, vm.QueuedMessages.Count);
Assert.True(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveQueueChanged_EmptyList_ClearsQueue()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "msg1" });
worker.RaiseInteractiveQueueChanged("t1", Array.Empty<string>());
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveQueueChanged_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveQueueChanged("other", new[] { "msg1" });
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
}
[Fact]
public void InteractiveEnded_ClearsQueuedMessages()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveQueueChanged("t1", new[] { "pending msg" });
worker.RaiseInteractiveEnded("t1");
Assert.Empty(vm.QueuedMessages);
Assert.False(vm.HasQueuedMessages);
} }
[Fact] [Fact]