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:
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="❯"
|
<TextBlock Grid.Column="0" Text="❯"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user