Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs
Mika Kuns afe7218b7c feat(ui): remove a queued interactive message with a ✕
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).
2026-06-26 16:11:53 +02:00

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; }
}