1188 lines
46 KiB
C#
1188 lines
46 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.Services.Interfaces;
|
|
using ClaudeDo.Ui.ViewModels.Agent;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using System.IO;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
|
|
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient _worker;
|
|
private readonly IServiceProvider _services;
|
|
private readonly INotesApi _notesApi;
|
|
private readonly IMergeCoordinator _merge;
|
|
|
|
// ── Section view models ───────────────────────────────────────────────────
|
|
public AgentConfigEditorViewModel 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;
|
|
|
|
// Which section of the details card is shown (header acts as a segment switcher).
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsDescriptionSection))]
|
|
[NotifyPropertyChangedFor(nameof(IsStepsSection))]
|
|
[NotifyPropertyChangedFor(nameof(IsFilesSection))]
|
|
private string _detailSection = "description";
|
|
|
|
public bool IsDescriptionSection => DetailSection == "description";
|
|
public bool IsStepsSection => DetailSection == "steps";
|
|
public bool IsFilesSection => DetailSection == "files";
|
|
|
|
[RelayCommand]
|
|
private void SelectDetailSection(string? section) => DetailSection = section ?? "description";
|
|
|
|
public int TotalStepCount => Subtasks.Count;
|
|
public int DoneStepCount => Subtasks.Count(s => s.Done);
|
|
public string StepsBadge => TotalStepCount > 0 ? $"{DoneStepCount}/{TotalStepCount}" : "";
|
|
public string FilesBadge => Attachments.Count > 0 ? Attachments.Count.ToString() : "";
|
|
|
|
private void NotifyStepsChanged()
|
|
{
|
|
OnPropertyChanged(nameof(TotalStepCount));
|
|
OnPropertyChanged(nameof(DoneStepCount));
|
|
OnPropertyChanged(nameof(StepsBadge));
|
|
OnPropertyChanged(nameof(ComposedPreview));
|
|
}
|
|
|
|
public string ComposedPreview =>
|
|
ClaudeDo.Data.TaskPromptComposer.Compose(
|
|
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)),
|
|
Task is not null
|
|
? Attachments.Select(a => Path.Combine(new AttachmentStore().TaskDir(Task.Id), a.FileName))
|
|
: null);
|
|
|
|
[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}";
|
|
|
|
// ── Monitor forwarding ───────────────────────────────────────────────────
|
|
public TaskMonitorViewModel Monitor { get; }
|
|
|
|
public ObservableCollection<LogLineViewModel> Log => Monitor.Log;
|
|
|
|
public string AgentState
|
|
{
|
|
get => Monitor.AgentState;
|
|
set => Monitor.AgentState = value;
|
|
}
|
|
|
|
public string AgentStatusLabel => Monitor.AgentStatusLabel;
|
|
public bool IsIdle => Monitor.IsIdle;
|
|
public bool IsQueued => Monitor.IsQueued;
|
|
public bool IsRunning => Monitor.IsRunning;
|
|
public bool IsWaitingForReview => Monitor.IsWaitingForReview;
|
|
public bool IsWaitingForChildren => Monitor.IsWaitingForChildren;
|
|
public bool IsDone => Monitor.IsDone;
|
|
public bool IsFailed => Monitor.IsFailed;
|
|
public bool IsCancelled => Monitor.IsCancelled;
|
|
public bool ShowContinue => Monitor.ShowContinue;
|
|
public bool ShowResetAndRetry => Monitor.ShowResetAndRetry;
|
|
public bool ShowRoadblock => Monitor.ShowRoadblock;
|
|
public string RoadblockMessage => Monitor.RoadblockMessage;
|
|
public bool ShowSessionOutcome => Monitor.ShowSessionOutcome;
|
|
public bool ShowRoadblockCard => Monitor.ShowRoadblockCard;
|
|
|
|
public string? SessionOutcome
|
|
{
|
|
get => Monitor.SessionOutcome;
|
|
set => Monitor.SessionOutcome = value;
|
|
}
|
|
|
|
public string? Roadblocks
|
|
{
|
|
get => Monitor.Roadblocks;
|
|
set => Monitor.Roadblocks = value;
|
|
}
|
|
|
|
public string SessionLabel => "claude-session";
|
|
|
|
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
|
|
|
[ObservableProperty]
|
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
|
private string? _latestRunSessionId;
|
|
|
|
[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<SubtaskRowViewModel> Subtasks { get; } = new();
|
|
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
|
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();
|
|
|
|
[ObservableProperty] private bool _isDragOver;
|
|
[ObservableProperty] private string? _dropStatus;
|
|
|
|
public bool CanAcceptDrop => Task is not null && !Task.IsRunning;
|
|
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 = "";
|
|
|
|
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);
|
|
|
|
public DetailsIslandViewModel(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
IWorkerClient worker,
|
|
IServiceProvider services,
|
|
INotesApi notesApi,
|
|
IMergeCoordinator merge)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
_services = services;
|
|
_notesApi = notesApi;
|
|
_merge = merge;
|
|
|
|
Monitor = new TaskMonitorViewModel(dbFactory, worker);
|
|
Monitor.PropertyChanged += OnMonitorPropertyChanged;
|
|
|
|
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
|
|
Merge = new MergeSectionViewModel(worker, services);
|
|
Prep = new PrepPanelViewModel(worker);
|
|
|
|
Notes = new NotesEditorViewModel(_notesApi);
|
|
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
|
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
|
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
|
|
|
|
AgentSettings.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns))
|
|
OnPropertyChanged(nameof(TurnsText));
|
|
};
|
|
|
|
_langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
|
|
Loc.LanguageChanged += _langChangedHandler;
|
|
|
|
_workerPropertyChangedHandler = (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(IWorkerClient.IsConnected))
|
|
{
|
|
EnqueueCommand.NotifyCanExecuteChanged();
|
|
DequeueCommand.NotifyCanExecuteChanged();
|
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
|
ContinueCommand.NotifyCanExecuteChanged();
|
|
}
|
|
};
|
|
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
|
|
|
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
|
|
{
|
|
_ = RefreshChildOutcomeAsync(taskId);
|
|
};
|
|
_worker.TaskStartedEvent += _workerTaskStartedHandler;
|
|
|
|
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
|
|
{
|
|
if (Task?.Id != taskId) return;
|
|
_ = RefreshWorktreeAsync(taskId);
|
|
_ = RefreshChildOutcomeAsync(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?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
|
_ = RefreshChildOutcomeAsync(taskId);
|
|
};
|
|
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
|
|
|
ChildOutcomes.CollectionChanged += (_, _) =>
|
|
{
|
|
Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
|
NotifySessionSections();
|
|
};
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
|
|
Monitor.Dispose();
|
|
Loc.LanguageChanged -= _langChangedHandler;
|
|
_worker.PropertyChanged -= _workerPropertyChangedHandler;
|
|
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
|
|
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
|
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
|
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
|
AgentSettings.Dispose();
|
|
Prep.Dispose();
|
|
}
|
|
|
|
private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
switch (e.PropertyName)
|
|
{
|
|
case nameof(TaskMonitorViewModel.AgentState):
|
|
OnPropertyChanged(nameof(AgentState));
|
|
OnPropertyChanged(nameof(AgentStatusLabel));
|
|
OnPropertyChanged(nameof(IsIdle));
|
|
OnPropertyChanged(nameof(IsQueued));
|
|
OnPropertyChanged(nameof(IsRunning));
|
|
OnPropertyChanged(nameof(IsWaitingForReview));
|
|
OnPropertyChanged(nameof(IsWaitingForChildren));
|
|
OnPropertyChanged(nameof(IsDone));
|
|
OnPropertyChanged(nameof(IsFailed));
|
|
OnPropertyChanged(nameof(IsCancelled));
|
|
OnPropertyChanged(nameof(ShowContinue));
|
|
OnPropertyChanged(nameof(ShowResetAndRetry));
|
|
OnPropertyChanged(nameof(ShowRoadblock));
|
|
OnPropertyChanged(nameof(RoadblockMessage));
|
|
OnPropertyChanged(nameof(ShowSessionOutcome));
|
|
OnPropertyChanged(nameof(ShowRoadblockCard));
|
|
EnqueueCommand.NotifyCanExecuteChanged();
|
|
DequeueCommand.NotifyCanExecuteChanged();
|
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
|
ContinueCommand.NotifyCanExecuteChanged();
|
|
AgentSettings.IsRunning = IsRunning;
|
|
NotifySessionSections();
|
|
OnPropertyChanged(nameof(CanAcceptDrop));
|
|
break;
|
|
case nameof(TaskMonitorViewModel.SessionOutcome):
|
|
OnPropertyChanged(nameof(SessionOutcome));
|
|
OnPropertyChanged(nameof(ShowSessionOutcome));
|
|
break;
|
|
case nameof(TaskMonitorViewModel.Roadblocks):
|
|
OnPropertyChanged(nameof(Roadblocks));
|
|
OnPropertyChanged(nameof(ShowRoadblockCard));
|
|
break;
|
|
}
|
|
}
|
|
|
|
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));
|
|
Monitor.Reset();
|
|
Subtasks.Clear();
|
|
ChildOutcomes.Clear();
|
|
Attachments.Clear();
|
|
DropStatus = null;
|
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
|
Merge.Clear();
|
|
|
|
if (row == null)
|
|
{
|
|
EditableTitle = "";
|
|
EditableDescription = "";
|
|
Model = null;
|
|
WorktreePath = null;
|
|
WorktreeHeadCommit = null;
|
|
_listWorkingDir = null;
|
|
WorktreeStateLabel = null;
|
|
BranchLine = null;
|
|
DiffAdditions = 0;
|
|
DiffDeletions = 0;
|
|
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;
|
|
Monitor.ApplyState(entity.Status);
|
|
|
|
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
|
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
|
WorktreeStateLabel, _listWorkingDir);
|
|
|
|
await AgentSettings.LoadForTaskAsync(entity, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var runRepo = new TaskRunRepository(ctx);
|
|
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
LatestRunSessionId = latestRun?.SessionId;
|
|
Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
|
|
|
Monitor.SetTaskId(row.Id);
|
|
|
|
await Monitor.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 });
|
|
|
|
var attachmentRepo = new TaskAttachmentRepository(ctx);
|
|
var attachments = await attachmentRepo.ListByTaskIdAsync(row.Id, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
foreach (var a in attachments)
|
|
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
|
|
|
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 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;
|
|
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();
|
|
OnPropertyChanged(nameof(CanAcceptDrop));
|
|
}
|
|
|
|
[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;
|
|
Monitor.ApplyState(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")
|
|
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
|
}
|
|
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 */ }
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
|
{
|
|
if (Task is null) return;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var attachments = await new TaskAttachmentRepository(ctx).ListByTaskIdAsync(Task.Id);
|
|
Attachments.Clear();
|
|
foreach (var a in attachments)
|
|
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
|
OnPropertyChanged(nameof(ComposedPreview));
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
public async System.Threading.Tasks.Task AddFilesAsync(IReadOnlyList<(string FileName, Stream Content)> files)
|
|
{
|
|
DetailSection = "files";
|
|
if (Task is null || Task.IsRunning)
|
|
{
|
|
DropStatus = Loc.T("details.attachments.selectIdleTask");
|
|
return;
|
|
}
|
|
|
|
var store = new AttachmentStore();
|
|
var successes = new List<string>();
|
|
var failures = new List<string>();
|
|
|
|
foreach (var (fileName, content) in files)
|
|
{
|
|
try
|
|
{
|
|
var byteSize = await store.SaveAsync(Task.Id, fileName, content);
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var repo = new TaskAttachmentRepository(ctx);
|
|
var existing = await repo.GetAsync(Task.Id, fileName);
|
|
if (existing is not null)
|
|
{
|
|
existing.ByteSize = byteSize;
|
|
await repo.UpdateAsync(existing);
|
|
}
|
|
else
|
|
{
|
|
await repo.AddAsync(new ClaudeDo.Data.Models.TaskAttachmentEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = Task.Id,
|
|
FileName = fileName,
|
|
ByteSize = byteSize,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
}
|
|
successes.Add(fileName);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
failures.Add(string.Format(Loc.T("details.attachments.overLimitError"), fileName, ex.Message));
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
failures.Add(string.Format(Loc.T("details.attachments.invalidNameError"), fileName, ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failures.Add($"{fileName}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
await ReloadAttachmentsAsync();
|
|
|
|
if (failures.Count == 0)
|
|
{
|
|
var names = string.Join(", ", successes);
|
|
DropStatus = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
|
}
|
|
else if (successes.Count == 0)
|
|
{
|
|
DropStatus = string.Join(" · ", failures);
|
|
}
|
|
else
|
|
{
|
|
var names = string.Join(", ", successes);
|
|
var addedPart = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
|
DropStatus = addedPart + " · " + string.Join(" · ", failures);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async System.Threading.Tasks.Task RemoveAttachment(AttachmentRowViewModel? row)
|
|
{
|
|
if (row is null || Task is null) return;
|
|
try
|
|
{
|
|
new AttachmentStore().DeleteFile(Task.Id, row.FileName);
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
await new TaskAttachmentRepository(ctx).DeleteAsync(Task.Id, row.FileName);
|
|
await ReloadAttachmentsAsync();
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
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 class AttachmentRowViewModel
|
|
{
|
|
public required string FileName { get; init; }
|
|
public required long ByteSize { get; init; }
|
|
public string SizeText => ByteSize switch
|
|
{
|
|
>= 1024 * 1024 => $"{ByteSize / (1024.0 * 1024.0):F1} MB",
|
|
>= 1024 => $"{ByteSize / 1024.0:F1} KB",
|
|
_ => $"{ByteSize} B",
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|