feat(ui): extract TaskMonitorViewModel streaming core; DetailsIsland delegates

This commit is contained in:
Mika Kuns
2026-06-25 14:00:30 +02:00
parent 7b6a8f0852
commit aa7a49f634
2 changed files with 395 additions and 255 deletions

View File

@@ -1,11 +1,9 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.Services.Interfaces;
@@ -148,99 +146,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string DiffAddText => $"+{DiffAdditions}"; public string DiffAddText => $"+{DiffAdditions}";
public string DiffDelText => $"-{DiffDeletions}"; public string DiffDelText => $"-{DiffDeletions}";
public bool ShowRoadblock => IsFailed; // ── Monitor forwarding ───────────────────────────────────────────────────
public string RoadblockMessage => public TaskMonitorViewModel Monitor { get; }
IsFailed ? "The session ended with an error." : "";
[ObservableProperty] public ObservableCollection<LogLineViewModel> Log => Monitor.Log;
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
private string? _sessionOutcome;
public bool ShowSessionOutcome => public string AgentState
!string.IsNullOrWhiteSpace(SessionOutcome)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
private string? _roadblocks;
public bool ShowRoadblockCard =>
!string.IsNullOrWhiteSpace(Roadblocks)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
private const string RoadblockMarker = "Roadblocks reported during the run:";
private void ApplyOutcome(string? result, string? errorFallback)
{ {
if (string.IsNullOrWhiteSpace(result)) get => Monitor.AgentState;
{ set => Monitor.AgentState = value;
SessionOutcome = errorFallback;
Roadblocks = null;
return;
} }
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal); public string AgentStatusLabel => Monitor.AgentStatusLabel;
if (idx < 0) public bool IsIdle => Monitor.IsIdle;
public bool IsQueued => Monitor.IsQueued;
public bool IsRunning => Monitor.IsRunning;
public bool IsWaitingForReview => Monitor.IsWaitingForReview;
public bool IsWaitingForChildren => Monitor.IsWaitingForChildren;
public bool IsDone => Monitor.IsDone;
public bool IsFailed => Monitor.IsFailed;
public bool IsCancelled => Monitor.IsCancelled;
public bool ShowContinue => Monitor.ShowContinue;
public bool ShowResetAndRetry => Monitor.ShowResetAndRetry;
public bool ShowRoadblock => Monitor.ShowRoadblock;
public string RoadblockMessage => Monitor.RoadblockMessage;
public bool ShowSessionOutcome => Monitor.ShowSessionOutcome;
public bool ShowRoadblockCard => Monitor.ShowRoadblockCard;
public string? SessionOutcome
{ {
SessionOutcome = result; get => Monitor.SessionOutcome;
Roadblocks = null; set => Monitor.SessionOutcome = value;
return;
} }
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd(); public string? Roadblocks
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary; {
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim(); get => Monitor.Roadblocks;
set => Monitor.Roadblocks = value;
} }
public string SessionLabel => "claude-session"; public string SessionLabel => "claude-session";
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string _agentState = "idle";
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;
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId; private string? _latestRunSessionId;
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));
AgentSettings.IsRunning = IsRunning;
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
}
[ObservableProperty] private string? _model; [ObservableProperty] private string? _model;
[ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreePath;
@@ -272,7 +224,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
} }
public ObservableCollection<LogLineViewModel> Log { get; } = new();
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new(); public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new(); public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new(); public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();
@@ -302,11 +253,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string _newSubtaskTitle = ""; [ObservableProperty] private string _newSubtaskTitle = "";
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
private string? _subscribedTaskId;
private CancellationTokenSource? _loadCts; private CancellationTokenSource? _loadCts;
private bool _suppressDescSave; private bool _suppressDescSave;
@@ -332,56 +278,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback); public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback);
private 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",
};
private static string FinishedStatusToStateKey(string status) => status switch
{
"done" => "done",
"failed" => "failed",
"cancelled" => "cancelled",
"waiting_for_review" => "review",
"waiting_for_children" => "children",
_ => status.ToLowerInvariant(),
};
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 || Task?.Id != 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 (Task?.Id != taskId) return;
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
}
catch { }
}
public DetailsIslandViewModel( public DetailsIslandViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient worker, IWorkerClient worker,
@@ -395,6 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_notesApi = notesApi; _notesApi = notesApi;
_merge = merge; _merge = merge;
Monitor = new TaskMonitorViewModel(dbFactory, worker);
Monitor.PropertyChanged += OnMonitorPropertyChanged;
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task); AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services); Merge = new MergeSectionViewModel(worker, services);
Prep = new PrepPanelViewModel(worker); Prep = new PrepPanelViewModel(worker);
@@ -413,8 +312,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel)); _langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
Loc.LanguageChanged += _langChangedHandler; Loc.LanguageChanged += _langChangedHandler;
_worker.TaskMessageEvent += OnTaskMessage;
_workerPropertyChangedHandler = (_, e) => _workerPropertyChangedHandler = (_, e) =>
{ {
if (e.PropertyName == nameof(IWorkerClient.IsConnected)) if (e.PropertyName == nameof(IWorkerClient.IsConnected))
@@ -429,7 +326,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskStartedHandler = (slot, taskId, startedAt) => _workerTaskStartedHandler = (slot, taskId, startedAt) =>
{ {
if (Task?.Id == taskId) AgentState = "running";
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
}; };
_worker.TaskStartedEvent += _workerTaskStartedHandler; _worker.TaskStartedEvent += _workerTaskStartedHandler;
@@ -437,16 +333,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) => _workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
{ {
if (Task?.Id != taskId) return; if (Task?.Id != taskId) return;
FlushClaudeBuffer();
Log.Add(new LogLineViewModel
{
Kind = LogKind.Done,
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
});
AgentState = FinishedStatusToStateKey(status);
_ = RefreshWorktreeAsync(taskId); _ = RefreshWorktreeAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
_ = RefreshOutcomeAsync(taskId);
}; };
_worker.TaskFinishedEvent += _workerTaskFinishedHandler; _worker.TaskFinishedEvent += _workerTaskFinishedHandler;
@@ -460,7 +348,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskUpdatedHandler = taskId => _workerTaskUpdatedHandler = taskId =>
{ {
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
}; };
@@ -475,64 +362,56 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void Dispose() public void Dispose()
{ {
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
Monitor.Dispose();
Loc.LanguageChanged -= _langChangedHandler; Loc.LanguageChanged -= _langChangedHandler;
_worker.PropertyChanged -= _workerPropertyChangedHandler; _worker.PropertyChanged -= _workerPropertyChangedHandler;
_worker.TaskStartedEvent -= _workerTaskStartedHandler; _worker.TaskStartedEvent -= _workerTaskStartedHandler;
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler; _worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler; _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler; _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
_worker.TaskMessageEvent -= OnTaskMessage;
AgentSettings.Dispose(); AgentSettings.Dispose();
Prep.Dispose(); Prep.Dispose();
} }
private void OnTaskMessage(string taskId, string line) private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (taskId != _subscribedTaskId) return; switch (e.PropertyName)
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
{ {
var body = line["[stdout]".Length..].TrimStart(); case nameof(TaskMonitorViewModel.AgentState):
AppendStdoutLine(body); OnPropertyChanged(nameof(AgentState));
return; 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));
EnqueueCommand.NotifyCanExecuteChanged();
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
AgentSettings.IsRunning = IsRunning;
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
break;
case nameof(TaskMonitorViewModel.SessionOutcome):
OnPropertyChanged(nameof(SessionOutcome));
OnPropertyChanged(nameof(ShowSessionOutcome));
break;
case nameof(TaskMonitorViewModel.Roadblocks):
OnPropertyChanged(nameof(Roadblocks));
OnPropertyChanged(nameof(ShowRoadblockCard));
break;
} }
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 });
} }
partial void OnEditableDescriptionChanged(string value) partial void OnEditableDescriptionChanged(string value)
@@ -587,20 +466,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Task = row; Task = row;
OnPropertyChanged(nameof(TaskIdBadge)); OnPropertyChanged(nameof(TaskIdBadge));
Log.Clear(); Monitor.Reset();
Subtasks.Clear(); Subtasks.Clear();
ChildOutcomes.Clear(); ChildOutcomes.Clear();
Attachments.Clear(); Attachments.Clear();
DropStatus = null; DropStatus = null;
OnPropertyChanged(nameof(HasChildOutcomes)); OnPropertyChanged(nameof(HasChildOutcomes));
SessionOutcome = null;
Roadblocks = null;
_claudeBuf.Clear();
Merge.Clear(); Merge.Clear();
if (row == null) if (row == null)
{ {
_subscribedTaskId = null;
EditableTitle = ""; EditableTitle = "";
EditableDescription = ""; EditableDescription = "";
Model = null; Model = null;
@@ -611,7 +486,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
BranchLine = null; BranchLine = null;
DiffAdditions = 0; DiffAdditions = 0;
DiffDeletions = 0; DiffDeletions = 0;
AgentState = "idle";
LatestRunSessionId = null; LatestRunSessionId = null;
AgentSettings.Clear(); AgentSettings.Clear();
return; return;
@@ -649,7 +523,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
DiffAdditions = add; DiffAdditions = add;
DiffDeletions = del; DiffDeletions = del;
AgentState = StatusToStateKey(entity.Status); Monitor.ApplyState(entity.Status);
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent); Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
@@ -662,11 +536,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct); var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId; LatestRunSessionId = latestRun?.SessionId;
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
_subscribedTaskId = row.Id; Monitor.SetTaskId(row.Id);
await ReplayLogFileAsync(entity.LogPath, ct); await Monitor.ReplayLogFileAsync(entity.LogPath, ct);
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id); var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -746,56 +620,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* best-effort */ } catch { /* best-effort */ }
} }
private 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 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;
}
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct) private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{ {
try try
@@ -899,7 +723,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
WorktreeHeadCommit = entity.Worktree?.HeadCommit; WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString(); WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentState = StatusToStateKey(entity.Status);
if (Task is { } row && entity.Worktree?.DiffStat is { } stat) if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
row.DiffStat = stat; row.DiffStat = stat;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
@@ -959,7 +782,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
? ClaudeDo.Data.Models.TaskStatus.Done ? ClaudeDo.Data.Models.TaskStatus.Done
: ClaudeDo.Data.Models.TaskStatus.Idle; : ClaudeDo.Data.Models.TaskStatus.Idle;
Task.Status = entity.Status; Task.Status = entity.Status;
AgentState = StatusToStateKey(entity.Status); Monitor.ApplyState(entity.Status);
await repo.UpdateAsync(entity); await repo.UpdateAsync(entity);
} }

View File

@@ -0,0 +1,317 @@
using System.Collections.ObjectModel;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
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";
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))]
private string? _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;
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);
_ = RefreshOutcomeAsync(taskId);
};
_worker.TaskFinishedEvent += _onTaskFinished;
_onTaskUpdated = taskId =>
{
if (taskId == _subscribedTaskId)
_ = RefreshStatusAsync(taskId);
};
_worker.TaskUpdatedEvent += _onTaskUpdated;
}
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;
}
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;
}
}