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:
@@ -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",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user