Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
Mika Kuns 3e4e4a03f7 feat(ui): move review feedback to the Output tab + review/worktree polish
- Feedback box + a new "Resume session" button move from the Git tab to the
  Output tab; the Git review block keeps Approve & Merge / Park / Cancel / Reset.
- Add a "Parked" chip for Idle tasks that still hold an Active worktree.
- Stop showing the "Session was Cancelled" band on cancel (failed-only now).
- Fix the Worktrees-overview state-chip contrast (dark text on the colour).
2026-06-19 09:31:53 +02:00

1272 lines
49 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;
// ── Section view models ───────────────────────────────────────────────────
public AgentSettingsSectionViewModel AgentSettings { get; }
public MergeSectionViewModel Merge { get; }
public PrepPanelViewModel Prep { get; }
// 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;
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))]
[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;
[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));
}
public string ComposedPreview =>
ClaudeDo.Data.TaskPromptComposer.Compose(
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
[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";
private void NotifySessionSections()
{
OnPropertyChanged(nameof(HasChildOutcomes));
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
NotifyAttention();
if (!HasChildOutcomes && SelectedTab == "session")
SelectedTab = "output";
}
public string TurnsText => $"{Turns}/{AgentSettings.EffectiveMaxTurns}";
public string DiffAddText => $"+{DiffAdditions}";
public string DiffDelText => $"-{DiffDeletions}";
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:";
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";
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();
}
[ObservableProperty] private string? _model;
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _worktreeHeadCommit;
[ObservableProperty] private string? _worktreeStateLabel;
private string? _listWorkingDir;
[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 => "";
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)); }
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();
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| c.RoadblockCount > 0);
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
? "1 child needs attention"
: $"{ChildrenNeedingAttention} children need attention";
private void NotifyAttention()
{
OnPropertyChanged(nameof(ChildrenNeedingAttention));
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
OnPropertyChanged(nameof(ChildrenAttentionText));
}
[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;
private CancellationTokenSource? _descSaveCts;
// 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 DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
// ── Review ──────────────────────────────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasReviewFeedback))]
[NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))]
private string _reviewFeedback = "";
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback);
// Kept for backwards-compat surface — delegates to Merge.RequestConflictResolution
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution
{
get => Merge.RequestConflictResolution;
set => Merge.RequestConflictResolution = value;
}
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,
IServiceProvider services,
INotesApi notesApi)
{
_dbFactory = dbFactory;
_worker = worker;
_services = services;
_notesApi = notesApi;
AgentSettings = new AgentSettingsSectionViewModel(worker);
Merge = new MergeSectionViewModel(worker, services);
Prep = new PrepPanelViewModel(worker);
Notes = new NotesEditorViewModel(_notesApi);
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
AgentSettings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns))
OnPropertyChanged(nameof(TurnsText));
};
_langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
Loc.LanguageChanged += _langChangedHandler;
_worker.TaskMessageEvent += OnTaskMessage;
_workerPropertyChangedHandler = (_, e) =>
{
if (e.PropertyName == nameof(IWorkerClient.IsConnected))
{
EnqueueCommand.NotifyCanExecuteChanged();
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
}
};
_worker.PropertyChanged += _workerPropertyChangedHandler;
_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);
_ = 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;
ChildOutcomes.CollectionChanged += (_, _) =>
{
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
NotifySessionSections();
};
}
public void 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)
{
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 });
}
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 { }
}
public void ShowNotes()
{
Bind(null);
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
}
public void ShowPrep()
{
Bind(null);
IsNotesMode = false;
IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync();
}
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));
SessionOutcome = null;
Roadblocks = null;
_claudeBuf.Clear();
Merge.Clear();
if (row == null)
{
_subscribedTaskId = null;
EditableTitle = "";
EditableDescription = "";
Model = null;
WorktreePath = null;
WorktreeHeadCommit = null;
_listWorkingDir = null;
WorktreeStateLabel = null;
BranchLine = null;
DiffAdditions = 0;
DiffDeletions = 0;
AgentState = "idle";
LatestRunSessionId = null;
AgentSettings.Clear();
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);
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.List)
.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;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
DiffAdditions = add;
DiffDeletions = del;
AgentState = StatusToStateKey(entity.Status);
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
AgentSettings.TaskId = row.Id;
await AgentSettings.LoadAsync(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);
_subscribedTaskId = row.Id;
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);
await LoadChildOutcomesAsync(row.Id, ct);
if (entity.Worktree != null
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
&& Merge.MergeTargetBranches.Count == 0)
{
var targets = await _worker.GetMergeTargetsAsync(row.Id);
if (targets != null)
{
Merge.MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
}
}
await Merge.RefreshMergePreviewAsync();
}
catch (OperationCanceledException) { }
}
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 (Merge.MergeTargetBranches.Count == 0)
{
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
if (childWithWorktree != null)
{
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
if (targets != null)
{
Merge.MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.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;
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 (Merge.MergeTargetBranches.Count == 0)
{
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
if (childWithWorktree != null)
{
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
if (targets != null)
{
Merge.MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.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 */ }
}
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;
Merge.ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
NotifyAttention();
}
catch { /* best-effort */ }
}
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)
.Include(t => t.List)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity == null || Task?.Id != taskId) return;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
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);
DiffAdditions = add;
DiffDeletions = del;
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
}
catch { /* best-effort refresh */ }
}
partial void OnWorktreePathChanged(string? value)
{
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
NotifySessionSections();
}
partial void OnWorktreeHeadCommitChanged(string? value) =>
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
partial void OnTaskChanged(TaskRowViewModel? value)
{
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
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 (Microsoft.EntityFrameworkCore.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);
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 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, Merge.SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
{
if (Merge.RequestConflictResolution is not null)
{
await Merge.RequestConflictResolution(Task.Id, Merge.SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
Merge.MergePreviewText = text;
Merge.MergeIsClean = false;
Merge.MergeIsConflict = true;
}
}
}
catch (Exception ex)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
}
}
[RelayCommand(CanExecute = nameof(HasReviewFeedback))]
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 = "";
}
// Park: set the task aside (back to Idle), keeping its worktree intact.
[RelayCommand]
private async System.Threading.Tasks.Task ParkReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
catch { /* stale review action; broadcast reconciles */ }
}
[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 */ }
}
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";
}