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

@@ -1,6 +1,6 @@
namespace ClaudeDo.Ui.ViewModels.Islands; 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 public sealed class LogLineViewModel
{ {
@@ -16,6 +16,7 @@ public sealed class LogLineViewModel
LogKind.Stderr => "err", LogKind.Stderr => "err",
LogKind.Done => "done", LogKind.Done => "done",
LogKind.Msg => "claude", LogKind.Msg => "claude",
LogKind.User => "you",
_ => "", _ => "",
}; };
public string ClassName => Kind switch public string ClassName => Kind switch
@@ -27,6 +28,7 @@ public sealed class LogLineViewModel
LogKind.Stderr => "log-stderr", LogKind.Stderr => "log-stderr",
LogKind.Done => "log-done", LogKind.Done => "log-done",
LogKind.Msg => "log-msg", LogKind.Msg => "log-msg",
LogKind.User => "log-user",
_ => "", _ => "",
}; };
} }

View File

@@ -77,6 +77,17 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private readonly Action<string> _onTaskUpdated; private readonly Action<string> _onTaskUpdated;
private readonly Action<string, string, string> _onTaskQuestionAsked; private readonly Action<string, string, string> _onTaskQuestionAsked;
private readonly Action<string, string> _onTaskQuestionResolved; private readonly Action<string, string> _onTaskQuestionResolved;
private readonly Action<string> _onInteractiveStarted;
private readonly Action<string> _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 // 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. // 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(); ClearPendingQuestion();
}; };
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved; _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). // Surface a pending question (used by live event + re-attach hydration).
@@ -153,6 +176,33 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
PendingQuestion = question; 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() private void ClearPendingQuestion()
{ {
PendingQuestionId = null; PendingQuestionId = null;
@@ -201,6 +251,8 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
SessionOutcome = null; SessionOutcome = null;
Roadblocks = null; Roadblocks = null;
ClearPendingQuestion(); ClearPendingQuestion();
IsInteractiveLive = false;
ComposerDraft = string.Empty;
} }
[ObservableProperty] [ObservableProperty]
@@ -424,5 +476,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
_worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked; _worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved; _worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
} }
} }

View File

@@ -19,6 +19,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished; private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated; private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored; private readonly Action _onConnectionRestored;
private readonly Action<string> _onInteractiveStarted;
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new(); public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
@@ -67,6 +68,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored; _worker.ConnectionRestoredEvent += _onConnectionRestored;
_onInteractiveStarted = taskId =>
{
EnsureMonitor(taskId);
var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId);
m?.SetInteractiveLive(true);
};
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
SeedActive(); SeedActive();
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
} }
@@ -209,6 +218,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskFinishedEvent -= _onTaskFinished; _worker.TaskFinishedEvent -= _onTaskFinished;
_worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
Monitors.CollectionChanged -= OnMonitorsChanged; Monitors.CollectionChanged -= OnMonitorsChanged;
foreach (var m in Monitors) m.Dispose(); foreach (var m in Monitors) m.Dispose();
Monitors.Clear(); Monitors.Clear();

View File

@@ -57,6 +57,9 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line); public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
public void RaisePrepFinished(bool ok) => PrepFinishedEvent?.Invoke(ok); 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 IsConnected => false;
public virtual bool IsReconnecting => false; public virtual bool IsReconnecting => false;
public virtual string? LastApproveTarget => null; 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 SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public virtual Task SendInteractiveMessageAsync(string taskId, string text) => Task.CompletedTask; public List<(string TaskId, string Text)> SentInteractive { get; } = new();
public virtual Task StopInteractiveSessionAsync(string taskId) => Task.CompletedTask; public virtual Task SendInteractiveMessageAsync(string taskId, string text)
{
SentInteractive.Add((taskId, text));
return Task.CompletedTask;
}
public List<string> 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)); protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
} }

View File

@@ -189,4 +189,112 @@ public class TaskMonitorViewModelTests : IDisposable
Assert.False(vm.HasPendingQuestion); 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);
}
} }