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 _dbFactory; private readonly IWorkerClient _worker; private readonly StreamLineFormatter _formatter = new(); private readonly StringBuilder _claudeBuf = new(); private string? _subscribedTaskId; public string? SubscribedTaskId => _subscribedTaskId; public ObservableCollection 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 QueuedMessages { get; } = new(); public bool HasQueuedMessages => QueuedMessages.Count > 0; // Captured handler delegates for disposal private readonly Action _onTaskMessage; private readonly Action _onTaskStarted; private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onTaskQuestionAsked; private readonly Action _onTaskQuestionResolved; private readonly Action _onInteractiveStarted; private readonly Action _onInteractiveEnded; private readonly Action> _onInteractiveQueueChanged; private readonly Action _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 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? OpenInAppRequested { get; set; } // Set by the host (Mission Control) to pop this monitor out into its own window. public Action? 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(); 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; } }