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.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
@@ -148,99 +146,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string DiffAddText => $"+{DiffAdditions}";
public string DiffDelText => $"-{DiffDeletions}";
public bool ShowRoadblock => IsFailed;
public string RoadblockMessage =>
IsFailed ? "The session ended with an error." : "";
// ── Monitor forwarding ───────────────────────────────────────────────────
public TaskMonitorViewModel Monitor { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
private string? _sessionOutcome;
public ObservableCollection<LogLineViewModel> Log => Monitor.Log;
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:";
private void ApplyOutcome(string? result, string? errorFallback)
public string AgentState
{
if (string.IsNullOrWhiteSpace(result))
{
SessionOutcome = errorFallback;
Roadblocks = null;
return;
}
get => Monitor.AgentState;
set => Monitor.AgentState = value;
}
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
if (idx < 0)
{
SessionOutcome = result;
Roadblocks = null;
return;
}
public string AgentStatusLabel => Monitor.AgentStatusLabel;
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;
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
public string? SessionOutcome
{
get => Monitor.SessionOutcome;
set => Monitor.SessionOutcome = value;
}
public string? Roadblocks
{
get => Monitor.Roadblocks;
set => Monitor.Roadblocks = value;
}
public string SessionLabel => "claude-session";
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]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
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? _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<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();
@@ -302,11 +253,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[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 bool _suppressDescSave;
@@ -332,56 +278,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
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(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient worker,
@@ -395,6 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_notesApi = notesApi;
_merge = merge;
Monitor = new TaskMonitorViewModel(dbFactory, worker);
Monitor.PropertyChanged += OnMonitorPropertyChanged;
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services);
Prep = new PrepPanelViewModel(worker);
@@ -413,8 +312,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
Loc.LanguageChanged += _langChangedHandler;
_worker.TaskMessageEvent += OnTaskMessage;
_workerPropertyChangedHandler = (_, e) =>
{
if (e.PropertyName == nameof(IWorkerClient.IsConnected))
@@ -429,7 +326,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
{
if (Task?.Id == taskId) AgentState = "running";
_ = RefreshChildOutcomeAsync(taskId);
};
_worker.TaskStartedEvent += _workerTaskStartedHandler;
@@ -437,16 +333,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
{
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);
_ = RefreshChildOutcomeAsync(taskId);
_ = RefreshOutcomeAsync(taskId);
};
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
@@ -460,7 +348,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_workerTaskUpdatedHandler = taskId =>
{
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
};
@@ -475,64 +362,56 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void Dispose()
{
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
Monitor.Dispose();
Loc.LanguageChanged -= _langChangedHandler;
_worker.PropertyChanged -= _workerPropertyChangedHandler;
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
_worker.TaskMessageEvent -= OnTaskMessage;
AgentSettings.Dispose();
Prep.Dispose();
}
private void OnTaskMessage(string taskId, string line)
private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (taskId != _subscribedTaskId) return;
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
switch (e.PropertyName)
{
var body = line["[stdout]".Length..].TrimStart();
AppendStdoutLine(body);
return;
case nameof(TaskMonitorViewModel.AgentState):
OnPropertyChanged(nameof(AgentState));
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)
@@ -587,20 +466,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Task = row;
OnPropertyChanged(nameof(TaskIdBadge));
Log.Clear();
Monitor.Reset();
Subtasks.Clear();
ChildOutcomes.Clear();
Attachments.Clear();
DropStatus = null;
OnPropertyChanged(nameof(HasChildOutcomes));
SessionOutcome = null;
Roadblocks = null;
_claudeBuf.Clear();
Merge.Clear();
if (row == null)
{
_subscribedTaskId = null;
EditableTitle = "";
EditableDescription = "";
Model = null;
@@ -611,7 +486,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
BranchLine = null;
DiffAdditions = 0;
DiffDeletions = 0;
AgentState = "idle";
LatestRunSessionId = null;
AgentSettings.Clear();
return;
@@ -649,7 +523,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
DiffAdditions = add;
DiffDeletions = del;
AgentState = StatusToStateKey(entity.Status);
Monitor.ApplyState(entity.Status);
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
@@ -662,11 +536,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
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);
ct.ThrowIfCancellationRequested();
@@ -746,56 +620,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
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)
{
try
@@ -899,7 +723,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentState = StatusToStateKey(entity.Status);
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
row.DiffStat = stat;
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.Idle;
Task.Status = entity.Status;
AgentState = StatusToStateKey(entity.Status);
Monitor.ApplyState(entity.Status);
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;
}
}