Approve & Merge is now the only review+merge entry. For a parent with children it drives the unit merge via the worker (conflicts still surface through the existing PlanningMergeConflict dialog); the separate Merge All Subtasks button, MergeAllCommand, CanMergeAll plumbing, and the dead MergeAllPlanningAsync client method are removed. Combined-diff preview and conflict continue/abort are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1567 lines
62 KiB
C#
1567 lines
62 KiB
C#
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;
|
||
using ClaudeDo.Ui.ViewModels.Modals;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||
|
||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
|
||
|
||
public sealed class LogLineViewModel
|
||
{
|
||
public required LogKind Kind { get; init; }
|
||
public required string Text { get; init; }
|
||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||
public string KindMarker => Kind switch
|
||
{
|
||
LogKind.Sys => "sys",
|
||
LogKind.Tool => "tool",
|
||
LogKind.Claude => "claude",
|
||
LogKind.Stdout => "out",
|
||
LogKind.Stderr => "err",
|
||
LogKind.Done => "done",
|
||
LogKind.Msg => "claude",
|
||
_ => "",
|
||
};
|
||
public string ClassName => Kind switch
|
||
{
|
||
LogKind.Sys => "log-sys",
|
||
LogKind.Tool => "log-tool",
|
||
LogKind.Claude => "log-claude",
|
||
LogKind.Stdout => "log-stdout",
|
||
LogKind.Stderr => "log-stderr",
|
||
LogKind.Done => "log-done",
|
||
LogKind.Msg => "log-msg",
|
||
_ => "",
|
||
};
|
||
}
|
||
|
||
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||
{
|
||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||
private readonly IWorkerClient _worker;
|
||
private readonly IServiceProvider _services;
|
||
private readonly INotesApi _notesApi;
|
||
|
||
// Captured handler delegates for disposal
|
||
private readonly EventHandler _langChangedHandler;
|
||
private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler;
|
||
private readonly Action<string, string, DateTime> _workerTaskStartedHandler;
|
||
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
||
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
||
private readonly Action<string> _workerTaskUpdatedHandler;
|
||
|
||
[ObservableProperty] private bool _isNotesMode;
|
||
[ObservableProperty] private bool _isPrepMode;
|
||
[ObservableProperty] private bool _isPrepRunning;
|
||
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
||
public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;
|
||
|
||
partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));
|
||
partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible));
|
||
|
||
public NotesEditorViewModel Notes { get; private set; } = null!;
|
||
|
||
// Current task row (set by IslandsShellViewModel via Bind)
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||
[NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))]
|
||
[NotifyPropertyChangedFor(nameof(TaskIdBadge))]
|
||
private TaskRowViewModel? _task;
|
||
|
||
// Editable fields
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
||
private string _editableTitle = "";
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
||
private string _editableDescription = "";
|
||
[ObservableProperty] private bool _isEditingDescription;
|
||
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||
|
||
public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
|
||
public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;
|
||
|
||
partial void OnIsDescriptionExpandedChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
|
||
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
|
||
}
|
||
|
||
partial void OnIsEditingDescriptionChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
|
||
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription;
|
||
|
||
[RelayCommand]
|
||
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||
|
||
// ── Description / Steps card (redesign) ─────────────────────────────
|
||
// Description is always the card body; steps live in an expandable summary
|
||
// strip below it so step presence is visible without switching views.
|
||
[ObservableProperty] private bool _isStepsExpanded;
|
||
|
||
[RelayCommand]
|
||
private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded;
|
||
|
||
public int TotalStepCount => Subtasks.Count;
|
||
public int OpenStepCount => Subtasks.Count(s => !s.Done);
|
||
public string StepsSummary =>
|
||
TotalStepCount == 0 ? "no steps yet"
|
||
: OpenStepCount == 0 ? $"all done · {TotalStepCount} total"
|
||
: $"{OpenStepCount} open · {TotalStepCount} total";
|
||
|
||
private void NotifyStepsChanged()
|
||
{
|
||
OnPropertyChanged(nameof(TotalStepCount));
|
||
OnPropertyChanged(nameof(OpenStepCount));
|
||
OnPropertyChanged(nameof(StepsSummary));
|
||
OnPropertyChanged(nameof(ComposedPreview));
|
||
}
|
||
|
||
// The exact text handed to Claude: title + description + open steps only.
|
||
public string ComposedPreview =>
|
||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
|
||
|
||
// ── Work console (redesign) ────────────────────────────────────────
|
||
// Two tabs: Output (live log) and Session (review + merge/worktree +
|
||
// outcomes, each section gated on the relevant state).
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
||
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
||
private string _selectedTab = "output";
|
||
|
||
public bool IsOutputTab => SelectedTab == "output";
|
||
public bool IsGitTab => SelectedTab == "git";
|
||
public bool IsSessionTab => SelectedTab == "session";
|
||
|
||
[RelayCommand]
|
||
private void SelectTab(string? tab) => SelectedTab = tab ?? "output";
|
||
|
||
// Merge/worktree controls only matter once there's a worktree to manage
|
||
// (standalone task), or a planning parent / improvement parent with children.
|
||
public bool ShowMergeSection =>
|
||
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
|
||
|
||
private void NotifySessionSections()
|
||
{
|
||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||
OnPropertyChanged(nameof(ShowMergeSection));
|
||
|
||
// The Session tab is only visible when it has outcomes; if it just
|
||
// emptied while selected, fall back to Output so the body isn't blank.
|
||
if (!HasChildOutcomes && SelectedTab == "session")
|
||
SelectedTab = "output";
|
||
}
|
||
|
||
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
|
||
public string DiffAddText => $"+{DiffAdditions}";
|
||
public string DiffDelText => $"-{DiffDeletions}";
|
||
|
||
// Resolved turn budget: per-task override → list default → global default.
|
||
public int EffectiveMaxTurns =>
|
||
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||
|
||
public bool ShowRoadblock => IsFailed || IsCancelled;
|
||
public string RoadblockMessage =>
|
||
IsFailed ? "The session ended with an error." :
|
||
IsCancelled ? "The session was cancelled." : "";
|
||
|
||
// The session's outcome summary — the task's Result minus any roadblock
|
||
// section (those get their own card), falling back to the run's
|
||
// ErrorMarkdown for hard failures. Shown once a run has finished.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
||
private string? _sessionOutcome;
|
||
|
||
public bool ShowSessionOutcome =>
|
||
!string.IsNullOrWhiteSpace(SessionOutcome)
|
||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||
|
||
// The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the
|
||
// run result so they can surface as a distinct colored card.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
||
private string? _roadblocks;
|
||
|
||
public bool ShowRoadblockCard =>
|
||
!string.IsNullOrWhiteSpace(Roadblocks)
|
||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||
|
||
// Worker writes roadblocks into the result under this header
|
||
// (TaskRunner.ComposeReviewResult). Split it back out for display.
|
||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||
|
||
private 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 string SessionLabel => "claude-session";
|
||
|
||
// Short task-id badge, e.g. "#T1A"
|
||
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";
|
||
|
||
// Recovery actions: Continue (resume session) for Failed/Cancelled.
|
||
public bool ShowContinue => IsFailed || IsCancelled;
|
||
// Reset & retry available from any terminal state.
|
||
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(IsAgentSectionEnabled));
|
||
OnPropertyChanged(nameof(ShowRoadblock));
|
||
OnPropertyChanged(nameof(RoadblockMessage));
|
||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||
NotifySessionSections();
|
||
}
|
||
[ObservableProperty] private string? _model;
|
||
|
||
// Agent settings overrides
|
||
[ObservableProperty] private string? _taskModelSelection; // null = inherit
|
||
[ObservableProperty] private string _taskSystemPrompt = "";
|
||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||
[ObservableProperty] private decimal? _taskMaxTurns; // null = inherit
|
||
[ObservableProperty] private string _modelBadge = "";
|
||
[ObservableProperty] private string _modelInheritedHint = "";
|
||
[ObservableProperty] private string _turnsBadge = "";
|
||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||
[ObservableProperty] private string _agentBadge = "";
|
||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||
|
||
private string _globalModel = ModelRegistry.DefaultAlias;
|
||
private int _globalMaxTurns = 100;
|
||
private string? _listModel;
|
||
private int? _listMaxTurns;
|
||
private string? _listAgentName;
|
||
|
||
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||
partial void OnTaskMaxTurnsChanged(decimal? value)
|
||
{
|
||
RecomputeTurnsBadge();
|
||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||
OnPropertyChanged(nameof(TurnsText));
|
||
QueueAgentSave();
|
||
}
|
||
|
||
private void RecomputeModelBadge()
|
||
{
|
||
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
|
||
ModelInheritedHint = value;
|
||
ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection));
|
||
}
|
||
|
||
private void RecomputeTurnsBadge()
|
||
{
|
||
var (value, source) = InheritanceResolver.Resolve(
|
||
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
|
||
TurnsInheritedHint = value;
|
||
TurnsBadge = BadgeFor(source, TaskMaxTurns is not null);
|
||
}
|
||
|
||
private void RecomputeAgentBadge()
|
||
{
|
||
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
|
||
var (_, source) = InheritanceResolver.Resolve(
|
||
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
|
||
AgentBadge = BadgeFor(source, taskSet);
|
||
}
|
||
|
||
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
|
||
? Loc.T("settings.inherit.overrideBadge")
|
||
: source == InheritSource.List
|
||
? Loc.T("settings.inherit.inheritedFromList")
|
||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||
|
||
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
||
|
||
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||
|
||
private bool _suppressAgentSave;
|
||
private CancellationTokenSource? _agentSaveCts;
|
||
|
||
private bool _suppressDescSave;
|
||
private CancellationTokenSource? _descSaveCts;
|
||
|
||
public bool IsAgentSectionEnabled => !IsRunning;
|
||
|
||
[ObservableProperty] private string? _worktreePath;
|
||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||
[ObservableProperty] private string? _worktreeStateLabel;
|
||
[ObservableProperty] private string? _branchLine;
|
||
[ObservableProperty] private int _turns;
|
||
[ObservableProperty] private int _tokens;
|
||
[ObservableProperty] private int _diffAdditions;
|
||
[ObservableProperty] private int _diffDeletions;
|
||
[ObservableProperty] private int _commitsOnBranch;
|
||
|
||
public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString();
|
||
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
||
|
||
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
||
partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText));
|
||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); }
|
||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); }
|
||
|
||
// 0.0–1.0 additions share for the diff meter
|
||
public double DiffMeterRatio
|
||
{
|
||
get
|
||
{
|
||
var total = DiffAdditions + DiffDeletions;
|
||
return total == 0 ? 0.0 : (double)DiffAdditions / total;
|
||
}
|
||
}
|
||
|
||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||
|
||
// Agent-suggested improvement children of a non-planning parent, surfaced on its
|
||
// review card with each child's outcome and rolled-up roadblock count.
|
||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||
|
||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||
|
||
// Planning merge controls
|
||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||
[ObservableProperty] private string? _selectedMergeTarget;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||
private string _mergePreviewText = "";
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||
private bool _mergeIsClean;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||
private bool _mergeIsConflict;
|
||
|
||
public bool ShowMergePreviewMuted =>
|
||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||
|
||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||
private readonly StreamLineFormatter _formatter = new();
|
||
private readonly StringBuilder _claudeBuf = new();
|
||
private readonly StringBuilder _prepClaudeBuf = new();
|
||
|
||
// The task ID we are currently subscribed to for live log messages
|
||
private string? _subscribedTaskId;
|
||
|
||
private CancellationTokenSource? _loadCts;
|
||
|
||
// Set by shell so CloseDetailCommand can clear SelectedTask
|
||
public Action? CloseDetail { get; set; }
|
||
|
||
// Set by shell so DeleteTaskCommand can remove from list
|
||
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
|
||
|
||
// Set by the view so OpenDiffCommand can show the modal as a dialog
|
||
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||
|
||
// Set by the view so OpenDiff can pass through merge requests from the diff modal
|
||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||
|
||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||
|
||
// Set by the view so ReviewCombinedDiffCommand can show the planning diff modal
|
||
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||
|
||
// Set by the view so DeleteTaskCommand can show an error message
|
||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||
|
||
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
||
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||
|
||
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 { }
|
||
}
|
||
|
||
// Reload the session outcome (task Result incl. roadblocks, or the run's
|
||
// error) so it appears as soon as a run finishes.
|
||
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, IServiceProvider services, INotesApi notesApi)
|
||
{
|
||
_dbFactory = dbFactory;
|
||
_worker = worker;
|
||
_services = services;
|
||
_notesApi = notesApi;
|
||
Notes = new NotesEditorViewModel(_notesApi);
|
||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||
_langChangedHandler = (_, _) =>
|
||
{
|
||
OnPropertyChanged(nameof(AgentStatusLabel));
|
||
RecomputeModelBadge();
|
||
RecomputeTurnsBadge();
|
||
RecomputeAgentBadge();
|
||
};
|
||
Loc.LanguageChanged += _langChangedHandler;
|
||
|
||
// Subscribe once; filter by current task id inside the handler
|
||
_worker.TaskMessageEvent += OnTaskMessage;
|
||
_worker.PrepStartedEvent += OnPrepStarted;
|
||
_worker.PrepLineEvent += OnPrepLine;
|
||
_worker.PrepFinishedEvent += OnPrepFinished;
|
||
|
||
// Re-evaluate CanExecute when worker connection flips.
|
||
_workerPropertyChangedHandler = (_, e) =>
|
||
{
|
||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||
{
|
||
EnqueueCommand.NotifyCanExecuteChanged();
|
||
DequeueCommand.NotifyCanExecuteChanged();
|
||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||
ContinueCommand.NotifyCanExecuteChanged();
|
||
}
|
||
};
|
||
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||
|
||
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
||
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
|
||
{
|
||
if (Task?.Id == taskId) AgentState = "running";
|
||
_ = RefreshChildOutcomeAsync(taskId);
|
||
};
|
||
_worker.TaskStartedEvent += _workerTaskStartedHandler;
|
||
|
||
_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);
|
||
// Re-query to pick up worktree created during the run.
|
||
_ = RefreshWorktreeAsync(taskId);
|
||
_ = RefreshChildOutcomeAsync(taskId);
|
||
_ = RefreshOutcomeAsync(taskId);
|
||
};
|
||
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
|
||
|
||
_workerWorktreeUpdatedHandler = taskId =>
|
||
{
|
||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||
_ = RefreshChildOutcomeAsync(taskId);
|
||
};
|
||
_worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler;
|
||
|
||
_workerTaskUpdatedHandler = taskId =>
|
||
{
|
||
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||
_ = RefreshChildOutcomeAsync(taskId);
|
||
};
|
||
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
||
|
||
Subtasks.CollectionChanged += (_, _) =>
|
||
{
|
||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||
};
|
||
|
||
ChildOutcomes.CollectionChanged += (_, _) =>
|
||
{
|
||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||
NotifySessionSections();
|
||
};
|
||
|
||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
Loc.LanguageChanged -= _langChangedHandler;
|
||
_worker.PropertyChanged -= _workerPropertyChangedHandler;
|
||
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
|
||
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||
_worker.TaskMessageEvent -= OnTaskMessage;
|
||
_worker.PrepStartedEvent -= OnPrepStarted;
|
||
_worker.PrepLineEvent -= OnPrepLine;
|
||
_worker.PrepFinishedEvent -= OnPrepFinished;
|
||
}
|
||
|
||
private void OnTaskMessage(string taskId, string line)
|
||
{
|
||
if (taskId != _subscribedTaskId) return;
|
||
|
||
// `[stdout] ...json...` lines are Claude CLI stream-json; parse through the
|
||
// formatter so the user sees human text, not raw JSON.
|
||
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var body = line["[stdout]".Length..].TrimStart();
|
||
AppendStdoutLine(Log, body);
|
||
return;
|
||
}
|
||
|
||
// Non-stdout tagged lines: flush any buffered text then classify by prefix.
|
||
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(ObservableCollection<LogLineViewModel> target, string line)
|
||
{
|
||
var formatted = _formatter.FormatLine(line);
|
||
if (formatted is null) return;
|
||
var buf = ReferenceEquals(target, Log) ? _claudeBuf : _prepClaudeBuf;
|
||
AppendClaudeText(formatted, target, buf);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task PlanDayAsync()
|
||
{
|
||
if (_worker is null) return;
|
||
try { await _worker.RunDailyPrepNowAsync(); }
|
||
catch { /* worker offline; PrepStarted/PrepLine will reconcile */ }
|
||
}
|
||
|
||
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||
|
||
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||
|
||
private void OnPrepStarted()
|
||
{
|
||
PrepLog.Clear();
|
||
IsPrepRunning = true;
|
||
}
|
||
|
||
private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line);
|
||
|
||
private void OnPrepFinished(bool success) => IsPrepRunning = false;
|
||
|
||
private void AppendClaudeText(string chunk) => AppendClaudeText(chunk, Log, _claudeBuf);
|
||
|
||
private static void AppendClaudeText(string chunk, ObservableCollection<LogLineViewModel> target, StringBuilder buf)
|
||
{
|
||
buf.Append(chunk);
|
||
// Emit a log entry for every completed line; keep the trailing remainder buffered.
|
||
while (true)
|
||
{
|
||
var text = buf.ToString();
|
||
var nl = text.IndexOf('\n');
|
||
if (nl < 0) break;
|
||
var piece = text[..nl].TrimEnd('\r');
|
||
if (!string.IsNullOrWhiteSpace(piece))
|
||
target.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||
buf.Clear();
|
||
buf.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 OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||
partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); }
|
||
|
||
partial void OnEditableDescriptionChanged(string value)
|
||
{
|
||
if (_suppressDescSave || Task is null) return;
|
||
_descSaveCts?.Cancel();
|
||
_descSaveCts = new CancellationTokenSource();
|
||
_ = SaveDescriptionAsync(_descSaveCts.Token);
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await System.Threading.Tasks.Task.Delay(400, ct);
|
||
if (Task is null) return;
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
var entity = await repo.GetByIdAsync(Task.Id);
|
||
if (entity is null) return;
|
||
entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription;
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch { }
|
||
}
|
||
|
||
private void QueueAgentSave()
|
||
{
|
||
if (_suppressAgentSave || Task is null) return;
|
||
_agentSaveCts?.Cancel();
|
||
_agentSaveCts = new CancellationTokenSource();
|
||
var ct = _agentSaveCts.Token;
|
||
_ = SaveAgentSettingsAsync(ct);
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await System.Threading.Tasks.Task.Delay(300, ct);
|
||
if (Task is null) return;
|
||
|
||
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
|
||
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||
? null : TaskSelectedAgent.Path;
|
||
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
|
||
|
||
await _worker.UpdateTaskAgentSettingsAsync(
|
||
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap, turns));
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch { }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task LoadAgentSettingsAsync(
|
||
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||
{
|
||
_suppressAgentSave = true;
|
||
try
|
||
{
|
||
TaskAgentOptions.Clear();
|
||
TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));
|
||
var agents = await _worker.GetAgentsAsync();
|
||
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||
|
||
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
|
||
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
|
||
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||
? TaskAgentOptions[0]
|
||
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||
|
||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||
var app = await _worker.GetAppSettingsAsync();
|
||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||
_listModel = listCfg?.Model;
|
||
_listMaxTurns = listCfg?.MaxTurns;
|
||
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||
|
||
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "" : listCfg!.SystemPrompt!;
|
||
|
||
RecomputeModelBadge();
|
||
RecomputeTurnsBadge();
|
||
RecomputeAgentBadge();
|
||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||
OnPropertyChanged(nameof(TurnsText));
|
||
}
|
||
finally
|
||
{
|
||
_suppressAgentSave = false;
|
||
}
|
||
}
|
||
|
||
public void ShowNotes()
|
||
{
|
||
Bind(null);
|
||
IsPrepMode = false;
|
||
IsNotesMode = true;
|
||
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||
}
|
||
|
||
public void ShowPrep()
|
||
{
|
||
Bind(null);
|
||
IsNotesMode = false;
|
||
IsPrepMode = true;
|
||
_ = LoadLastPrepLogIfEmptyAsync();
|
||
}
|
||
|
||
public async Task LoadLastPrepLogIfEmptyAsync()
|
||
{
|
||
if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return;
|
||
string text;
|
||
try { text = await _worker.GetLastPrepLogAsync(); }
|
||
catch { return; }
|
||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||
foreach (var line in text.Split('\n'))
|
||
{
|
||
var trimmed = line.TrimEnd('\r');
|
||
if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed);
|
||
}
|
||
}
|
||
|
||
public void Bind(TaskRowViewModel? row)
|
||
{
|
||
IsNotesMode = false;
|
||
IsPrepMode = false;
|
||
_loadCts?.Cancel();
|
||
_loadCts?.Dispose();
|
||
_loadCts = new CancellationTokenSource();
|
||
var ct = _loadCts.Token;
|
||
|
||
Task = row;
|
||
OnPropertyChanged(nameof(TaskIdBadge));
|
||
Log.Clear();
|
||
Subtasks.Clear();
|
||
ChildOutcomes.Clear();
|
||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||
MergeTargetBranches.Clear();
|
||
SelectedMergeTarget = null;
|
||
SessionOutcome = null;
|
||
Roadblocks = null;
|
||
_claudeBuf.Clear();
|
||
|
||
if (row == null)
|
||
{
|
||
_subscribedTaskId = null;
|
||
EditableTitle = "";
|
||
EditableDescription = "";
|
||
Model = null;
|
||
WorktreePath = null;
|
||
WorktreeStateLabel = null;
|
||
BranchLine = null;
|
||
DiffAdditions = 0;
|
||
DiffDeletions = 0;
|
||
AgentState = "idle";
|
||
LatestRunSessionId = null;
|
||
_suppressAgentSave = true;
|
||
try
|
||
{
|
||
TaskModelSelection = null;
|
||
TaskMaxTurns = null;
|
||
TaskSystemPrompt = "";
|
||
TaskSelectedAgent = null;
|
||
}
|
||
finally
|
||
{
|
||
_suppressAgentSave = false;
|
||
}
|
||
EffectiveSystemPromptHint = "";
|
||
return;
|
||
}
|
||
|
||
_ = BindAsync(row, ct);
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||
var subtaskRepo = new SubtaskRepository(ctx);
|
||
|
||
// Own query with Include so WorktreePath/BranchLine are populated.
|
||
var entity = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
if (entity == null) return;
|
||
|
||
EditableTitle = entity.Title;
|
||
_suppressDescSave = true;
|
||
try { EditableDescription = entity.Description ?? ""; }
|
||
finally { _suppressDescSave = false; }
|
||
Model = entity.Model;
|
||
WorktreePath = entity.Worktree?.Path;
|
||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||
DiffAdditions = add;
|
||
DiffDeletions = del;
|
||
AgentState = StatusToStateKey(entity.Status);
|
||
await LoadAgentSettingsAsync(entity, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
var runRepo = new TaskRunRepository(ctx);
|
||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
LatestRunSessionId = latestRun?.SessionId;
|
||
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||
|
||
// Subscribe only after DB load confirms the task exists
|
||
_subscribedTaskId = row.Id;
|
||
|
||
// Replay the latest run's persisted log so output is visible across app restarts.
|
||
await ReplayLogFileAsync(entity.LogPath, ct);
|
||
|
||
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||
ct.ThrowIfCancellationRequested();
|
||
foreach (var s in subs)
|
||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||
|
||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||
{
|
||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||
}
|
||
else
|
||
{
|
||
await LoadChildOutcomesAsync(row.Id, ct);
|
||
}
|
||
|
||
if (entity.Worktree != null
|
||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||
&& MergeTargetBranches.Count == 0)
|
||
{
|
||
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
||
if (targets != null)
|
||
{
|
||
MergeTargetBranches.Clear();
|
||
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
||
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
||
}
|
||
}
|
||
await RefreshMergePreviewAsync();
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
}
|
||
|
||
// Improvement parents (non-planning) surface their children's outcomes + roadblocks
|
||
// on the review card, and reuse the planning merge controls to fold the tree in.
|
||
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||
var children = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.Where(t => t.ParentTaskId == parentTaskId)
|
||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||
.ToListAsync(ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
if (children.Count == 0) return;
|
||
|
||
ChildOutcomes.Clear();
|
||
foreach (var c in children)
|
||
ChildOutcomes.Add(new ChildOutcomeRowViewModel
|
||
{
|
||
Id = c.Id,
|
||
Title = c.Title,
|
||
Status = c.Status,
|
||
RoadblockCount = c.RoadblockCount,
|
||
WorktreeState = c.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
|
||
});
|
||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||
|
||
if (MergeTargetBranches.Count == 0)
|
||
{
|
||
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
|
||
if (childWithWorktree != null)
|
||
{
|
||
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
|
||
if (targets != null)
|
||
{
|
||
MergeTargetBranches.Clear();
|
||
foreach (var b in targets.LocalBranches)
|
||
MergeTargetBranches.Add(b);
|
||
SelectedMergeTarget = targets.DefaultBranch;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
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;
|
||
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
|
||
// it with a "[stdout] " prefix. Match the live-stream format so the same
|
||
// stream-json parser handles both.
|
||
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
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||
var children = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.Where(t => t.ParentTaskId == parentTaskId)
|
||
.ToListAsync(ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
foreach (var child in children)
|
||
{
|
||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||
if (existing != null)
|
||
{
|
||
existing.Status = child.Status;
|
||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|
||
}
|
||
|
||
if (MergeTargetBranches.Count == 0)
|
||
{
|
||
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
|
||
if (childWithWorktree != null)
|
||
{
|
||
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
|
||
if (targets != null)
|
||
{
|
||
MergeTargetBranches.Clear();
|
||
foreach (var b in targets.LocalBranches)
|
||
MergeTargetBranches.Add(b);
|
||
SelectedMergeTarget = targets.DefaultBranch;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
||
{
|
||
if (Task is null) return;
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||
var child = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
|
||
if (child == null) return;
|
||
|
||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||
if (existing != null)
|
||
{
|
||
existing.Status = child.Status;
|
||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|
||
|
||
}
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
// Live-update a single improvement child's outcome row from a task event. No-op if the
|
||
// updated task isn't one of this parent's children.
|
||
private async System.Threading.Tasks.Task RefreshChildOutcomeAsync(string childTaskId)
|
||
{
|
||
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
|
||
if (row is null) return;
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||
var child = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == childTaskId);
|
||
if (child is null) return;
|
||
row.Status = child.Status;
|
||
row.RoadblockCount = child.RoadblockCount;
|
||
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||
}
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||
{
|
||
if (Task is null || ShowPlanningDiffModal is null) return;
|
||
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main");
|
||
await vm.InitializeAsync();
|
||
await ShowPlanningDiffModal(vm);
|
||
}
|
||
|
||
private bool CanReviewDiff() => (Task?.IsPlanningParent == true && Subtasks.Any()) || HasChildOutcomes;
|
||
|
||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||
var entity = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||
if (entity == null || Task?.Id != taskId) return;
|
||
|
||
WorktreePath = entity.Worktree?.Path;
|
||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||
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);
|
||
DiffAdditions = add;
|
||
DiffDeletions = del;
|
||
}
|
||
catch { /* best-effort refresh */ }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||
{
|
||
if (Task is null || WorktreePath is null)
|
||
{
|
||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||
return;
|
||
}
|
||
// Only probe Active worktrees; terminal states show their label instead.
|
||
if (WorktreeStateLabel is { } label && label != "Active")
|
||
{
|
||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||
return;
|
||
}
|
||
var capturedTaskId = Task.Id;
|
||
var capturedTarget = SelectedMergeTarget;
|
||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
||
// Discard a probe that resolved after the user switched task or target.
|
||
if (Task?.Id != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||
{
|
||
if (WorktreePath == null || ShowDiffModal == null) return;
|
||
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
||
{
|
||
WorktreePath = WorktreePath,
|
||
BaseRef = WorktreeBaseCommit,
|
||
TaskId = Task?.Id,
|
||
TaskTitle = Task?.Title ?? "",
|
||
ShowMergeModal = ShowMergeModal,
|
||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||
};
|
||
await diffVm.LoadAsync();
|
||
await ShowDiffModal(diffVm);
|
||
}
|
||
|
||
private bool CanOpenDiff() => WorktreePath != null;
|
||
|
||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||
private void OpenWorktree()
|
||
{
|
||
if (WorktreePath == null) return;
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = WorktreePath,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch { /* explorer open is best-effort */ }
|
||
}
|
||
|
||
private bool CanOpenWorktree() => WorktreePath != null;
|
||
|
||
partial void OnSelectedMergeTargetChanged(string? value)
|
||
{
|
||
_ = RefreshMergePreviewAsync();
|
||
}
|
||
|
||
partial void OnWorktreePathChanged(string? value)
|
||
{
|
||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||
NotifySessionSections();
|
||
}
|
||
|
||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||
|
||
[RelayCommand]
|
||
private void CloseDetails() => CloseDetail?.Invoke();
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task ToggleStarAsync()
|
||
{
|
||
if (Task is null) return;
|
||
Task.IsStarred = !Task.IsStarred;
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
var entity = await repo.GetByIdAsync(Task.Id);
|
||
if (entity is null) return;
|
||
entity.IsStarred = Task.IsStarred;
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task ToggleDoneAsync()
|
||
{
|
||
if (Task is null) return;
|
||
Task.Done = !Task.Done;
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
var entity = await repo.GetByIdAsync(Task.Id);
|
||
if (entity is null) return;
|
||
entity.Status = Task.Done
|
||
? ClaudeDo.Data.Models.TaskStatus.Done
|
||
: ClaudeDo.Data.Models.TaskStatus.Idle;
|
||
Task.Status = entity.Status;
|
||
AgentState = StatusToStateKey(entity.Status);
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
row.Done = !row.Done;
|
||
NotifyStepsChanged();
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new SubtaskRepository(ctx);
|
||
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
||
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
|
||
if (entity is null) return;
|
||
entity.Completed = row.Done;
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task DeleteTaskAsync()
|
||
{
|
||
if (Task == null) return;
|
||
var row = Task;
|
||
if (ConfirmAsync != null)
|
||
{
|
||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||
if (!ok) return;
|
||
}
|
||
try
|
||
{
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
await repo.DeleteAsync(row.Id);
|
||
}
|
||
catch (DbUpdateException ex) when (
|
||
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
|
||
{
|
||
if (ShowErrorAsync != null)
|
||
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
|
||
return;
|
||
}
|
||
if (DeleteFromList != null)
|
||
await DeleteFromList(row);
|
||
CloseDetail?.Invoke();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
|
||
{
|
||
if (row is null || !row.IsEditing) return;
|
||
row.IsEditing = false;
|
||
|
||
var title = row.Title?.Trim() ?? "";
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new SubtaskRepository(ctx);
|
||
|
||
// Emptying the text removes the step.
|
||
if (string.IsNullOrEmpty(title))
|
||
{
|
||
await repo.DeleteAsync(row.Id);
|
||
Subtasks.Remove(row);
|
||
return;
|
||
}
|
||
|
||
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
||
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
|
||
if (entity is null) return;
|
||
if (entity.Title != title)
|
||
{
|
||
entity.Title = title;
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
row.Title = title;
|
||
OnPropertyChanged(nameof(ComposedPreview));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task AddSubtaskAsync()
|
||
{
|
||
if (Task is null) return;
|
||
var title = NewSubtaskTitle?.Trim();
|
||
if (string.IsNullOrEmpty(title)) return;
|
||
|
||
var entity = new ClaudeDo.Data.Models.SubtaskEntity
|
||
{
|
||
Id = Guid.NewGuid().ToString(),
|
||
TaskId = Task.Id,
|
||
Title = title,
|
||
Completed = false,
|
||
OrderNum = Subtasks.Count,
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
await new SubtaskRepository(ctx).AddAsync(entity);
|
||
|
||
Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed });
|
||
NewSubtaskTitle = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task StopAsync()
|
||
{
|
||
if (Task == null || !IsRunning) return;
|
||
if (!_worker.IsConnected) return;
|
||
try { await _worker.CancelTaskAsync(Task.Id); }
|
||
catch { /* offline */ }
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||
private async System.Threading.Tasks.Task EnqueueAsync()
|
||
{
|
||
if (Task == null) return;
|
||
try
|
||
{
|
||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||
AgentState = "queued";
|
||
}
|
||
catch { /* offline */ }
|
||
}
|
||
|
||
private bool CanEnqueue() =>
|
||
Task != null && _worker.IsConnected && IsIdle
|
||
&& (!Task.IsChild || Task.ParentFinalized);
|
||
|
||
[RelayCommand(CanExecute = nameof(CanDequeue))]
|
||
private async System.Threading.Tasks.Task DequeueAsync()
|
||
{
|
||
if (Task == null) return;
|
||
try
|
||
{
|
||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
|
||
AgentState = "idle";
|
||
}
|
||
catch { /* offline */ }
|
||
}
|
||
|
||
private bool CanDequeue() =>
|
||
Task != null && _worker.IsConnected && IsQueued;
|
||
|
||
[RelayCommand(CanExecute = nameof(CanContinue))]
|
||
private async System.Threading.Tasks.Task ContinueAsync()
|
||
{
|
||
if (Task == null) return;
|
||
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
|
||
}
|
||
|
||
private bool CanContinue() =>
|
||
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
||
|
||
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||
{
|
||
if (Task == null) return;
|
||
if (ConfirmAsync == null) return;
|
||
|
||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||
var ok = await ConfirmAsync(
|
||
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
|
||
if (!ok) return;
|
||
|
||
if (WorktreePath != null)
|
||
await _worker.ResetTaskAsync(Task.Id);
|
||
|
||
try
|
||
{
|
||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||
AgentState = "queued";
|
||
}
|
||
catch { /* offline */ }
|
||
}
|
||
|
||
private bool CanResetAndRetry() =>
|
||
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||
|
||
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
|
||
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
|
||
[RelayCommand] private void ResetTaskAgent() => TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
|
||
|
||
// ── Review actions ──────────────────────────────────────────────────────────
|
||
[ObservableProperty] private string _reviewFeedback = "";
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||
{
|
||
if (Task is null || !_worker.IsConnected) return;
|
||
try
|
||
{
|
||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||
if (!hasChildren && result?.Status == "conflict")
|
||
{
|
||
if (RequestConflictResolution is not null)
|
||
{
|
||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||
}
|
||
else
|
||
{
|
||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||
}
|
||
}
|
||
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
||
}
|
||
catch { /* stale review action; broadcast reconciles */ }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task RejectReviewAsync()
|
||
{
|
||
if (Task is null || !_worker.IsConnected) return;
|
||
var feedback = ReviewFeedback;
|
||
if (string.IsNullOrWhiteSpace(feedback)) return;
|
||
try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); }
|
||
catch { /* stale review action; broadcast reconciles */ return; }
|
||
ReviewFeedback = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task ResetReviewAsync()
|
||
{
|
||
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
|
||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||
var ok = await ConfirmAsync(
|
||
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
||
if (!ok) return;
|
||
try { await _worker.ResetTaskAsync(Task.Id); }
|
||
catch { /* stale review action; broadcast reconciles */ }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task CancelReviewAsync()
|
||
{
|
||
if (Task is null || !_worker.IsConnected) return;
|
||
try { await _worker.CancelReviewAsync(Task.Id); }
|
||
catch { /* stale review action; broadcast reconciles */ }
|
||
}
|
||
|
||
// ── Diff meter parser ───────────────────────────────────────────────────────
|
||
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||
{
|
||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
||
int add = 0, del = 0;
|
||
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion");
|
||
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion");
|
||
if (m1.Success) int.TryParse(m1.Groups[1].Value, out add);
|
||
if (m2.Success) int.TryParse(m2.Groups[1].Value, out del);
|
||
return (add, del);
|
||
}
|
||
}
|
||
|
||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||
{
|
||
public required string Id { get; init; }
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private bool _done;
|
||
[ObservableProperty] private bool _isEditing;
|
||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
|
||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|
||
|
||
// A suggested child's outcome on an improvement parent's review card. Observable so the
|
||
// row reflects the child's live status (Idle → Running → Done/Failed) as it executes.
|
||
public sealed partial class ChildOutcomeRowViewModel : ViewModelBase
|
||
{
|
||
public required string Id { get; init; }
|
||
public required string Title { get; init; }
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
private ClaudeDo.Data.Models.TaskStatus _status;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
|
||
[NotifyPropertyChangedFor(nameof(RoadblockText))]
|
||
private int _roadblockCount;
|
||
|
||
[ObservableProperty]
|
||
private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||
|
||
public string StatusLabel => Status switch
|
||
{
|
||
ClaudeDo.Data.Models.TaskStatus.Done => Loc.T("vm.taskStatus.done"),
|
||
ClaudeDo.Data.Models.TaskStatus.Failed => Loc.T("vm.taskStatus.failed"),
|
||
ClaudeDo.Data.Models.TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"),
|
||
ClaudeDo.Data.Models.TaskStatus.Running => Loc.T("vm.taskStatus.running"),
|
||
ClaudeDo.Data.Models.TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"),
|
||
ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => Loc.T("vm.taskStatus.waitingForChildren"),
|
||
_ => Loc.T("vm.taskStatus.idle"),
|
||
};
|
||
|
||
public bool HasRoadblock => RoadblockCount > 0;
|
||
public string RoadblockText => RoadblockCount == 1 ? "1 roadblock" : $"{RoadblockCount} roadblocks";
|
||
}
|