A --json-schema run can echo the structured {"summary":...} JSON as the
result text, which then landed verbatim in task.Result and rendered raw in
the OUTCOME card. Unwrap the summary in StreamAnalyzer when the result text
is itself such a JSON object (a plain markdown result is kept as-is), and
add a UI safety net in ApplyOutcome for rows already persisted as raw JSON.
453 lines
17 KiB
C#
453 lines
17 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
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 ClaudeDo.Ui.ViewModels.MissionControl;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
|
|
public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionControlPane, 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:";
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Surface a pending question (used by live event + re-attach hydration).
|
|
public void SetPendingQuestion(string questionId, string question)
|
|
{
|
|
PendingQuestionId = questionId;
|
|
PendingQuestion = question;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
[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 = UnwrapSummary(result);
|
|
Roadblocks = null;
|
|
return;
|
|
}
|
|
|
|
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
|
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : UnwrapSummary(summary);
|
|
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
|
}
|
|
|
|
/// <summary>Safety net for older rows persisted as raw structured-output JSON: if the outcome
|
|
/// is a JSON object with a string <c>summary</c>, show that; otherwise keep the text as-is.</summary>
|
|
private static string? UnwrapSummary(string? text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return text;
|
|
var trimmed = text.TrimStart();
|
|
if (trimmed.Length == 0 || trimmed[0] != '{') return text;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(trimmed);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
|
|
doc.RootElement.TryGetProperty("summary", out var s) &&
|
|
s.ValueKind == JsonValueKind.String)
|
|
{
|
|
var summary = s.GetString();
|
|
if (!string.IsNullOrWhiteSpace(summary)) return summary;
|
|
}
|
|
}
|
|
catch { }
|
|
return text;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|