- 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).
1272 lines
49 KiB
C#
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";
|
|
}
|