diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index cfafea4..4aaf4bf 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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 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 Log { get; } = new(); public ObservableCollection Subtasks { get; } = new(); public ObservableCollection ChildOutcomes { get; } = new(); public ObservableCollection 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 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(); - 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); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs new file mode 100644 index 0000000..d4a8486 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -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 _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"; + + 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 _onTaskMessage; + private readonly Action _onTaskStarted; + private readonly Action _onTaskFinished; + private readonly Action _onTaskUpdated; + + 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); + _ = 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(); + 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; + } +}