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;
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",
_ => "",
};
}

View File

@@ -77,6 +77,17 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private readonly Action<string> _onTaskUpdated;
private readonly Action<string, string, string> _onTaskQuestionAsked;
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
// 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;
}
}

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> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string> _onInteractiveStarted;
public ObservableCollection<TaskMonitorViewModel> 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();