Queued rows are now QueuedMessageViewModel (Text + RemoveCommand); each shows a ✕ (Icon.WinClose) that calls RemoveQueuedInteractiveMessageAsync(taskId, text). The worker re-broadcasts the queue, rebuilding the strip without the removed message. Adds session.composer.unqueue (en/de).
538 lines
20 KiB
C#
538 lines
20 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Text;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Helpers;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
|
|
public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient _worker;
|
|
|
|
private readonly StreamLineFormatter _formatter = new();
|
|
private readonly StringBuilder _claudeBuf = new();
|
|
private string? _subscribedTaskId;
|
|
|
|
public string? SubscribedTaskId => _subscribedTaskId;
|
|
|
|
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
|
|
|
[ObservableProperty] private string _agentState = "idle";
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
|
private string? _title;
|
|
|
|
public string DisplayTitle =>
|
|
string.IsNullOrWhiteSpace(Title) ? (SubscribedTaskId ?? "task") : Title!;
|
|
|
|
public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}");
|
|
public bool IsIdle => AgentState == "idle";
|
|
public bool IsQueued => AgentState == "queued";
|
|
public bool IsRunning => AgentState == "running";
|
|
public bool IsWaitingForReview => AgentState == "review";
|
|
public bool IsWaitingForChildren => AgentState == "children";
|
|
public bool IsDone => AgentState == "done";
|
|
public bool IsFailed => AgentState == "failed";
|
|
public bool IsCancelled => AgentState == "cancelled";
|
|
|
|
public bool ShowContinue => IsFailed || IsCancelled;
|
|
public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
|
|
|
|
public bool ShowRoadblock => IsFailed;
|
|
public string RoadblockMessage => IsFailed ? "The session ended with an error." : "";
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
|
private string? _sessionOutcome;
|
|
|
|
public bool ShowSessionOutcome =>
|
|
!string.IsNullOrWhiteSpace(SessionOutcome)
|
|
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
|
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
|
|
private string? _roadblocks;
|
|
|
|
public bool HasRoadblock => !string.IsNullOrWhiteSpace(Roadblocks);
|
|
|
|
public bool ShowRoadblockCard =>
|
|
!string.IsNullOrWhiteSpace(Roadblocks)
|
|
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
|
|
|
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
|
|
|
public ObservableCollection<QueuedMessageViewModel> QueuedMessages { get; } = new();
|
|
public bool HasQueuedMessages => QueuedMessages.Count > 0;
|
|
|
|
// Captured handler delegates for disposal
|
|
private readonly Action<string, string> _onTaskMessage;
|
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
|
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;
|
|
private readonly Action<string, IReadOnlyList<string>> _onInteractiveQueueChanged;
|
|
private readonly Action<string, string> _onInteractiveMessageSent;
|
|
|
|
// 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.
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(HasPendingQuestion))]
|
|
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
|
private string? _pendingQuestionId;
|
|
|
|
[ObservableProperty] private string? _pendingQuestion;
|
|
|
|
[ObservableProperty]
|
|
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
|
private string _answerDraft = string.Empty;
|
|
|
|
public bool HasPendingQuestion => PendingQuestionId is not null;
|
|
|
|
public TaskMonitorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
|
|
_onTaskMessage = OnTaskMessage;
|
|
_worker.TaskMessageEvent += _onTaskMessage;
|
|
|
|
_onTaskStarted = (slot, taskId, startedAt) =>
|
|
{
|
|
if (taskId == _subscribedTaskId)
|
|
AgentState = "running";
|
|
};
|
|
_worker.TaskStartedEvent += _onTaskStarted;
|
|
|
|
_onTaskFinished = (slot, taskId, status, finishedAt) =>
|
|
{
|
|
if (taskId != _subscribedTaskId) return;
|
|
FlushClaudeBuffer();
|
|
Log.Add(new LogLineViewModel
|
|
{
|
|
Kind = LogKind.Done,
|
|
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
|
});
|
|
AgentState = FinishedStatusToStateKey(status);
|
|
ClearPendingQuestion();
|
|
_ = RefreshOutcomeAsync(taskId);
|
|
};
|
|
_worker.TaskFinishedEvent += _onTaskFinished;
|
|
|
|
_onTaskUpdated = taskId =>
|
|
{
|
|
if (taskId == _subscribedTaskId)
|
|
_ = RefreshStatusAsync(taskId);
|
|
};
|
|
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
|
|
|
_onTaskQuestionAsked = (taskId, questionId, question) =>
|
|
{
|
|
if (taskId != _subscribedTaskId) return;
|
|
PendingQuestionId = questionId;
|
|
PendingQuestion = question;
|
|
};
|
|
_worker.TaskQuestionAskedEvent += _onTaskQuestionAsked;
|
|
|
|
_onTaskQuestionResolved = (taskId, questionId) =>
|
|
{
|
|
if (taskId == _subscribedTaskId && PendingQuestionId == questionId)
|
|
ClearPendingQuestion();
|
|
};
|
|
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved;
|
|
|
|
_onInteractiveStarted = taskId =>
|
|
{
|
|
if (taskId == _subscribedTaskId) { IsInteractiveLive = true; AgentState = "running"; }
|
|
};
|
|
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
|
|
|
_onInteractiveEnded = taskId =>
|
|
{
|
|
if (taskId != _subscribedTaskId) return;
|
|
IsInteractiveLive = false;
|
|
AgentState = "done";
|
|
QueuedMessages.Clear();
|
|
OnPropertyChanged(nameof(HasQueuedMessages));
|
|
};
|
|
_worker.InteractiveSessionEndedEvent += _onInteractiveEnded;
|
|
|
|
_onInteractiveQueueChanged = (taskId, pending) =>
|
|
{
|
|
if (taskId != _subscribedTaskId) return;
|
|
QueuedMessages.Clear();
|
|
foreach (var m in pending)
|
|
{
|
|
var text = m;
|
|
QueuedMessages.Add(new QueuedMessageViewModel
|
|
{
|
|
Text = text,
|
|
RemoveCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => _ = RemoveQueuedAsync(text)),
|
|
});
|
|
}
|
|
OnPropertyChanged(nameof(HasQueuedMessages));
|
|
};
|
|
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
|
|
|
|
_onInteractiveMessageSent = (taskId, text) =>
|
|
{
|
|
if (taskId == _subscribedTaskId)
|
|
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
|
|
};
|
|
_worker.InteractiveMessageSentEvent += _onInteractiveMessageSent;
|
|
}
|
|
|
|
// Surface a pending question (used by live event + re-attach hydration).
|
|
public void SetPendingQuestion(string questionId, string question)
|
|
{
|
|
PendingQuestionId = questionId;
|
|
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;
|
|
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);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async System.Threading.Tasks.Task InterruptInteractive()
|
|
{
|
|
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
|
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task RemoveQueuedAsync(string text)
|
|
{
|
|
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
|
await _worker.RemoveQueuedInteractiveMessageAsync(_subscribedTaskId, text);
|
|
}
|
|
|
|
private void ClearPendingQuestion()
|
|
{
|
|
PendingQuestionId = null;
|
|
PendingQuestion = null;
|
|
AnswerDraft = string.Empty;
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanSubmitAnswer))]
|
|
private async System.Threading.Tasks.Task SubmitAnswer()
|
|
{
|
|
var questionId = PendingQuestionId;
|
|
if (questionId is null || string.IsNullOrEmpty(_subscribedTaskId)) return;
|
|
var answer = AnswerDraft;
|
|
if (string.IsNullOrWhiteSpace(answer)) return;
|
|
ClearPendingQuestion(); // optimistic; the resolved event also clears
|
|
await _worker.AnswerTaskQuestionAsync(_subscribedTaskId, questionId, answer);
|
|
}
|
|
|
|
private bool CanSubmitAnswer() => HasPendingQuestion && !string.IsNullOrWhiteSpace(AnswerDraft);
|
|
|
|
partial void OnAgentStateChanged(string value)
|
|
{
|
|
OnPropertyChanged(nameof(AgentStatusLabel));
|
|
OnPropertyChanged(nameof(IsIdle));
|
|
OnPropertyChanged(nameof(IsQueued));
|
|
OnPropertyChanged(nameof(IsRunning));
|
|
OnPropertyChanged(nameof(IsWaitingForReview));
|
|
OnPropertyChanged(nameof(IsWaitingForChildren));
|
|
OnPropertyChanged(nameof(IsDone));
|
|
OnPropertyChanged(nameof(IsFailed));
|
|
OnPropertyChanged(nameof(IsCancelled));
|
|
OnPropertyChanged(nameof(ShowContinue));
|
|
OnPropertyChanged(nameof(ShowResetAndRetry));
|
|
OnPropertyChanged(nameof(ShowRoadblock));
|
|
OnPropertyChanged(nameof(RoadblockMessage));
|
|
OnPropertyChanged(nameof(ShowSessionOutcome));
|
|
OnPropertyChanged(nameof(ShowRoadblockCard));
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
Log.Clear();
|
|
_claudeBuf.Clear();
|
|
_subscribedTaskId = null;
|
|
AgentState = "idle";
|
|
SessionOutcome = null;
|
|
Roadblocks = null;
|
|
ClearPendingQuestion();
|
|
IsInteractiveLive = false;
|
|
ComposerDraft = string.Empty;
|
|
QueuedMessages.Clear();
|
|
OnPropertyChanged(nameof(HasQueuedMessages));
|
|
}
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(DetachTooltip))]
|
|
private bool _isDetached;
|
|
|
|
// Localized tooltip for the detach/re-dock toggle button.
|
|
public string DetachTooltip => Loc.T(IsDetached ? "missionControl.redock" : "missionControl.detach");
|
|
|
|
// Set by the detached window so the re-dock action can close it.
|
|
public Action? CloseWindowRequested { get; set; }
|
|
|
|
// Set by the host (e.g. Mission Control) to navigate the main app to this task.
|
|
public Action<string>? OpenInAppRequested { get; set; }
|
|
|
|
// Set by the host (Mission Control) to pop this monitor out into its own window.
|
|
public Action<TaskMonitorViewModel>? DetachRequested { get; set; }
|
|
|
|
[RelayCommand]
|
|
private void Detach()
|
|
{
|
|
if (IsDetached) CloseWindowRequested?.Invoke(); // re-dock: close the detached window
|
|
else DetachRequested?.Invoke(this); // detach: pop out to its own window
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenInApp()
|
|
{
|
|
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
|
OpenInAppRequested?.Invoke(_subscribedTaskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async System.Threading.Tasks.Task CancelTask()
|
|
{
|
|
if (!string.IsNullOrEmpty(_subscribedTaskId) && (IsRunning || IsQueued))
|
|
await _worker.CancelTaskAsync(_subscribedTaskId);
|
|
}
|
|
|
|
public void SetTaskId(string id) => _subscribedTaskId = id;
|
|
|
|
public void ApplyState(ClaudeDo.Data.Models.TaskStatus status) =>
|
|
AgentState = StatusToStateKey(status);
|
|
|
|
public void ApplyOutcome(string? result, string? errorFallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(result))
|
|
{
|
|
SessionOutcome = errorFallback;
|
|
Roadblocks = null;
|
|
return;
|
|
}
|
|
|
|
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
|
if (idx < 0)
|
|
{
|
|
SessionOutcome = result;
|
|
Roadblocks = null;
|
|
return;
|
|
}
|
|
|
|
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
|
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
|
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
|
}
|
|
|
|
public async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(logPath)) return;
|
|
var expanded = ExpandUserPath(logPath);
|
|
if (!System.IO.File.Exists(expanded)) return;
|
|
|
|
try
|
|
{
|
|
const int maxLines = 2000;
|
|
string[] all;
|
|
await using (var fs = new System.IO.FileStream(
|
|
expanded,
|
|
System.IO.FileMode.Open,
|
|
System.IO.FileAccess.Read,
|
|
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
|
using (var reader = new System.IO.StreamReader(fs))
|
|
{
|
|
var list = new List<string>();
|
|
while (await reader.ReadLineAsync(ct) is { } line)
|
|
list.Add(line);
|
|
all = list.ToArray();
|
|
}
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var start = Math.Max(0, all.Length - maxLines);
|
|
for (int i = start; i < all.Length; i++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (_subscribedTaskId is null) return;
|
|
var line = all[i];
|
|
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
|
OnTaskMessage(_subscribedTaskId, normalized);
|
|
}
|
|
FlushClaudeBuffer();
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { /* best-effort replay */ }
|
|
}
|
|
|
|
private void OnTaskMessage(string taskId, string line)
|
|
{
|
|
if (taskId != _subscribedTaskId) return;
|
|
|
|
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var body = line["[stdout]".Length..].TrimStart();
|
|
AppendStdoutLine(body);
|
|
return;
|
|
}
|
|
|
|
FlushClaudeBuffer();
|
|
|
|
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
|
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
|
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
|
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
|
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
|
: LogKind.Msg;
|
|
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
|
}
|
|
|
|
private void AppendStdoutLine(string line)
|
|
{
|
|
var formatted = _formatter.FormatLine(line);
|
|
if (formatted is null) return;
|
|
_claudeBuf.Append(formatted);
|
|
while (true)
|
|
{
|
|
var text = _claudeBuf.ToString();
|
|
var nl = text.IndexOf('\n');
|
|
if (nl < 0) break;
|
|
var piece = text[..nl].TrimEnd('\r');
|
|
if (!string.IsNullOrWhiteSpace(piece))
|
|
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
|
_claudeBuf.Clear();
|
|
_claudeBuf.Append(text[(nl + 1)..]);
|
|
}
|
|
}
|
|
|
|
private void FlushClaudeBuffer()
|
|
{
|
|
if (_claudeBuf.Length == 0) return;
|
|
var piece = _claudeBuf.ToString().TrimEnd();
|
|
_claudeBuf.Clear();
|
|
if (!string.IsNullOrWhiteSpace(piece))
|
|
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (entity is null || _subscribedTaskId != taskId) return;
|
|
|
|
AgentState = StatusToStateKey(entity.Status);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
|
if (_subscribedTaskId != taskId) return;
|
|
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
internal static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
|
|
{
|
|
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
|
|
ClaudeDo.Data.Models.TaskStatus.Running => "running",
|
|
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => "review",
|
|
ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => "children",
|
|
ClaudeDo.Data.Models.TaskStatus.Done => "done",
|
|
ClaudeDo.Data.Models.TaskStatus.Failed => "failed",
|
|
ClaudeDo.Data.Models.TaskStatus.Cancelled => "cancelled",
|
|
_ => "idle",
|
|
};
|
|
|
|
internal static string FinishedStatusToStateKey(string status) => status switch
|
|
{
|
|
"done" => "done",
|
|
"failed" => "failed",
|
|
"cancelled" => "cancelled",
|
|
"waiting_for_review" => "review",
|
|
"waiting_for_children" => "children",
|
|
_ => status.ToLowerInvariant(),
|
|
};
|
|
|
|
private static string ExpandUserPath(string path)
|
|
{
|
|
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
|
return System.IO.Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
path[2..]);
|
|
if (path == "~")
|
|
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
return path;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_worker.TaskMessageEvent -= _onTaskMessage;
|
|
_worker.TaskStartedEvent -= _onTaskStarted;
|
|
_worker.TaskFinishedEvent -= _onTaskFinished;
|
|
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
|
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
|
|
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
|
|
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
|
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
|
|
_worker.InteractiveQueueChangedEvent -= _onInteractiveQueueChanged;
|
|
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
|
|
}
|
|
}
|
|
|
|
public sealed class QueuedMessageViewModel
|
|
{
|
|
public required string Text { get; init; }
|
|
public required System.Windows.Input.ICommand RemoveCommand { get; init; }
|
|
}
|