feat(ui): interactive chat composer state on the session monitor VM

TaskMonitorViewModel gains IsInteractiveLive + ComposerDraft + SubmitComposer
(optimistic LogKind.User echo, then SendInteractiveMessageAsync) + StopInteractive,
driven by the InteractiveSessionStarted/Ended events. Since DetailsIslandViewModel
embeds this monitor, both task detail and Mission Control get the composer. Mission
Control auto-creates a monitor on InteractiveSessionStarted. Adds LogKind.User.
This commit is contained in:
Mika Kuns
2026-06-26 09:29:58 +02:00
parent 9effddeb2c
commit 140b8e1551
5 changed files with 190 additions and 3 deletions

View File

@@ -189,4 +189,112 @@ public class TaskMonitorViewModelTests : IDisposable
Assert.False(vm.HasPendingQuestion);
}
// ── Interactive composer ──────────────────────────────────────────────────
[Fact]
public void InteractiveStarted_ForSubscribedTask_SetsIsInteractiveLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
Assert.True(vm.IsInteractiveLive);
Assert.Equal("running", vm.AgentState);
}
[Fact]
public void InteractiveStarted_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("other");
Assert.False(vm.IsInteractiveLive);
}
[Fact]
public void InteractiveEnded_ForSubscribedTask_ClearsIsInteractiveLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveEnded("t1");
Assert.False(vm.IsInteractiveLive);
Assert.Equal("done", vm.AgentState);
}
[Fact]
public void InteractiveEnded_ForOtherTask_IsIgnored()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
worker.RaiseInteractiveEnded("other");
Assert.True(vm.IsInteractiveLive); // unchanged
}
[Fact]
public void SubmitComposerCommand_CanExecute_FalseWhenNotLive()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
vm.ComposerDraft = "hello";
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public void SubmitComposerCommand_CanExecute_FalseWhenLiveButDraftWhitespace()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = " ";
Assert.False(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public void SubmitComposerCommand_CanExecute_TrueWhenLiveAndDraftSet()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = "hello";
Assert.True(vm.SubmitComposerCommand.CanExecute(null));
}
[Fact]
public async Task SubmitComposer_AddsUserLogLine_ClearsDraft_CallsClient()
{
var worker = new FakeWorker();
using var vm = Build(worker);
vm.SetTaskId("t1");
worker.RaiseInteractiveStarted("t1");
vm.ComposerDraft = "do the thing";
await vm.SubmitComposerCommand.ExecuteAsync(null);
Assert.Single(worker.SentInteractive);
Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]);
Assert.Equal(string.Empty, vm.ComposerDraft);
Assert.Single(vm.Log);
Assert.Equal(LogKind.User, vm.Log[0].Kind);
Assert.Equal("do the thing", vm.Log[0].Text);
}
}