From 140b8e1551d3dbf90a3ce6b0bc48b74bbf845fbd Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 26 Jun 2026 09:29:58 +0200 Subject: [PATCH] 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. --- .../ViewModels/Islands/LogLineViewModel.cs | 4 +- .../Islands/TaskMonitorViewModel.cs | 54 +++++++++ .../ViewModels/MissionControlViewModel.cs | 10 ++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 17 ++- .../ViewModels/TaskMonitorViewModelTests.cs | 108 ++++++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs index eed803d..8b5b09c 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs @@ -1,6 +1,6 @@ namespace ClaudeDo.Ui.ViewModels.Islands; -public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg } +public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User } public sealed class LogLineViewModel { @@ -16,6 +16,7 @@ public sealed class LogLineViewModel LogKind.Stderr => "err", LogKind.Done => "done", LogKind.Msg => "claude", + LogKind.User => "you", _ => "", }; public string ClassName => Kind switch @@ -27,6 +28,7 @@ public sealed class LogLineViewModel LogKind.Stderr => "log-stderr", LogKind.Done => "log-done", LogKind.Msg => "log-msg", + LogKind.User => "log-user", _ => "", }; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index ed5938d..9074d89 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -77,6 +77,17 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable private readonly Action _onTaskUpdated; private readonly Action _onTaskQuestionAsked; private readonly Action _onTaskQuestionResolved; + private readonly Action _onInteractiveStarted; + private readonly Action _onInteractiveEnded; + + // Interactive composer — active while the worker is in an interactive session. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))] + private bool _isInteractiveLive; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))] + private string _composerDraft = string.Empty; // A question the running task raised via AskUser and is blocking on, plus the answer // the user is typing. Ephemeral (in-memory + live events) — the task is still Running. @@ -144,6 +155,18 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable ClearPendingQuestion(); }; _worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved; + + _onInteractiveStarted = taskId => + { + if (taskId == _subscribedTaskId) { IsInteractiveLive = true; AgentState = "running"; } + }; + _worker.InteractiveSessionStartedEvent += _onInteractiveStarted; + + _onInteractiveEnded = taskId => + { + if (taskId == _subscribedTaskId) { IsInteractiveLive = false; AgentState = "done"; } + }; + _worker.InteractiveSessionEndedEvent += _onInteractiveEnded; } // Surface a pending question (used by live event + re-attach hydration). @@ -153,6 +176,33 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable PendingQuestion = question; } + // Used by Mission Control when it creates the monitor after the started event already fired. + public void SetInteractiveLive(bool live) + { + IsInteractiveLive = live; + if (live) AgentState = "running"; + } + + [RelayCommand(CanExecute = nameof(CanSubmitComposer))] + private async System.Threading.Tasks.Task SubmitComposer() + { + if (string.IsNullOrEmpty(_subscribedTaskId)) return; + var text = ComposerDraft; + if (string.IsNullOrWhiteSpace(text)) return; + Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text }); + ComposerDraft = string.Empty; + await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text); + } + + private bool CanSubmitComposer() => IsInteractiveLive && !string.IsNullOrWhiteSpace(ComposerDraft); + + [RelayCommand] + private async System.Threading.Tasks.Task StopInteractive() + { + if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive) + await _worker.StopInteractiveSessionAsync(_subscribedTaskId); + } + private void ClearPendingQuestion() { PendingQuestionId = null; @@ -201,6 +251,8 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable SessionOutcome = null; Roadblocks = null; ClearPendingQuestion(); + IsInteractiveLive = false; + ComposerDraft = string.Empty; } [ObservableProperty] @@ -424,5 +476,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked; _worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved; + _worker.InteractiveSessionStartedEvent -= _onInteractiveStarted; + _worker.InteractiveSessionEndedEvent -= _onInteractiveEnded; } } diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 12fe34f..972c663 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -19,6 +19,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; + private readonly Action _onInteractiveStarted; public ObservableCollection Monitors { get; } = new(); @@ -67,6 +68,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; + _onInteractiveStarted = taskId => + { + EnsureMonitor(taskId); + var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId); + m?.SetInteractiveLive(true); + }; + _worker.InteractiveSessionStartedEvent += _onInteractiveStarted; + SeedActive(); _ = RefreshQueueAsync(); } @@ -209,6 +218,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.TaskFinishedEvent -= _onTaskFinished; _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; + _worker.InteractiveSessionStartedEvent -= _onInteractiveStarted; Monitors.CollectionChanged -= OnMonitorsChanged; foreach (var m in Monitors) m.Dispose(); Monitors.Clear(); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 0157c3f..5005274 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -57,6 +57,9 @@ public abstract class StubWorkerClient : IWorkerClient public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line); public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok); + public void RaiseInteractiveStarted(string taskId) => InteractiveSessionStartedEvent?.Invoke(taskId); + public void RaiseInteractiveEnded(string taskId) => InteractiveSessionEndedEvent?.Invoke(taskId); + public virtual bool IsConnected => false; public virtual bool IsReconnecting => false; public virtual string? LastApproveTarget => null; @@ -135,8 +138,18 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; - public virtual Task SendInteractiveMessageAsync(string taskId, string text) => Task.CompletedTask; - public virtual Task StopInteractiveSessionAsync(string taskId) => Task.CompletedTask; + public List<(string TaskId, string Text)> SentInteractive { get; } = new(); + public virtual Task SendInteractiveMessageAsync(string taskId, string text) + { + SentInteractive.Add((taskId, text)); + return Task.CompletedTask; + } + public List StoppedInteractive { get; } = new(); + public virtual Task StopInteractiveSessionAsync(string taskId) + { + StoppedInteractive.Add(taskId); + return Task.CompletedTask; + } protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs index e18c01d..9841f93 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskMonitorViewModelTests.cs @@ -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); + } }