584 lines
20 KiB
C#
584 lines
20 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Helpers;
|
|
using ClaudeDo.Ui.Services;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public partial class TaskDetailViewModel : ViewModelBase
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly GitService _git;
|
|
private readonly WorkerClient _worker;
|
|
|
|
[ObservableProperty] private string _title = "";
|
|
[ObservableProperty] private string? _description;
|
|
[ObservableProperty] private string? _result;
|
|
[ObservableProperty] private string? _logPath;
|
|
[ObservableProperty] private string _statusText = "";
|
|
[ObservableProperty] private string _statusChoice = "Manual";
|
|
[ObservableProperty] private string _commitType = "chore";
|
|
[ObservableProperty] private string _modelChoice = "(list default)";
|
|
[ObservableProperty] private string? _systemPromptOverride;
|
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
|
public List<AgentInfo> AvailableAgents { get; } = [];
|
|
|
|
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
|
|
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
|
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
|
|
|
// Worktree
|
|
[ObservableProperty] private bool _hasWorktree;
|
|
[ObservableProperty] private string? _branchName;
|
|
[ObservableProperty] private string? _diffStat;
|
|
[ObservableProperty] private string? _worktreePath;
|
|
[ObservableProperty] private string _worktreeState = "";
|
|
|
|
// Live stream
|
|
[ObservableProperty] private string _liveText = "";
|
|
private StreamLineFormatter _formatter = new();
|
|
public ObservableCollection<TagEntity> Tags { get; } = new();
|
|
[ObservableProperty] private string _newTagInput = "";
|
|
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
|
|
|
private string? _taskId;
|
|
public string? CurrentTaskId => _taskId;
|
|
private string? _listId;
|
|
private bool _isLoading;
|
|
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
|
|
// before the previous load finished — prevents torn state on _taskId,
|
|
// Subtasks, Tags, etc.
|
|
private CancellationTokenSource? _loadCts;
|
|
|
|
public event Action<string>? TaskChanged;
|
|
|
|
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_git = git;
|
|
_worker = worker;
|
|
|
|
worker.TaskMessageEvent += OnTaskMessage;
|
|
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
|
worker.TaskUpdatedEvent += OnTaskUpdated;
|
|
worker.RunNowRequestedEvent += OnRunNowRequested;
|
|
worker.TaskStartedEvent += OnTaskStarted;
|
|
}
|
|
|
|
public async Task LoadAsync(string taskId)
|
|
{
|
|
// Cancel any in-flight load so rapid TaskUpdated events don't race
|
|
// on _taskId / Subtasks / Tags. The newest caller wins.
|
|
var oldCts = _loadCts;
|
|
var cts = new CancellationTokenSource();
|
|
_loadCts = cts;
|
|
oldCts?.Cancel();
|
|
oldCts?.Dispose();
|
|
var ct = cts.Token;
|
|
|
|
_taskId = taskId;
|
|
HasWorktree = false;
|
|
WorktreeState = "";
|
|
BranchName = null;
|
|
DiffStat = null;
|
|
WorktreePath = null;
|
|
OnPropertyChanged(nameof(CanWorktreeAction));
|
|
LiveText = "";
|
|
_formatter = new StreamLineFormatter();
|
|
|
|
try
|
|
{
|
|
TaskEntity? task;
|
|
List<TagEntity> tags;
|
|
List<SubtaskEntity> subtasks;
|
|
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var taskRepo = new TaskRepository(context);
|
|
task = await taskRepo.GetByIdAsync(taskId, ct);
|
|
if (task is null) return;
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
tags = await taskRepo.GetTagsAsync(taskId, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var subtaskRepo = new SubtaskRepository(context);
|
|
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
|
}
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
if (AvailableAgents.Count == 0)
|
|
{
|
|
var agents = await _worker.GetAgentsAsync();
|
|
ct.ThrowIfCancellationRequested();
|
|
AvailableAgents.AddRange(agents);
|
|
OnPropertyChanged(nameof(AvailableAgents));
|
|
}
|
|
|
|
_isLoading = true;
|
|
try
|
|
{
|
|
_listId = task.ListId;
|
|
Title = task.Title;
|
|
Description = task.Description;
|
|
Result = task.Result;
|
|
LogPath = task.LogPath;
|
|
if (task.LogPath is not null
|
|
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
|
&& File.Exists(task.LogPath))
|
|
{
|
|
_formatter = new StreamLineFormatter();
|
|
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
|
}
|
|
StatusText = task.Status.ToString().ToLowerInvariant();
|
|
StatusChoice = task.Status.ToString();
|
|
CommitType = task.CommitType;
|
|
ModelChoice = task.Model is not null
|
|
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
|
: "(list default)";
|
|
SystemPromptOverride = task.SystemPrompt;
|
|
if (task.AgentPath is not null)
|
|
{
|
|
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
|
if (match is null)
|
|
{
|
|
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
|
AvailableAgents.Add(match);
|
|
OnPropertyChanged(nameof(AvailableAgents));
|
|
}
|
|
SelectedAgent = match;
|
|
}
|
|
else
|
|
{
|
|
SelectedAgent = null;
|
|
}
|
|
|
|
Tags.Clear();
|
|
foreach (var tag in tags)
|
|
Tags.Add(tag);
|
|
|
|
// Tear down old subtask subscriptions before replacing them.
|
|
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
Subtasks.Clear();
|
|
foreach (var s in subtasks)
|
|
{
|
|
var vm = SubtaskItemViewModel.From(s);
|
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
|
Subtasks.Add(vm);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isLoading = false;
|
|
}
|
|
|
|
await LoadWorktreeAsync(taskId);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Superseded by a newer LoadAsync — nothing to do.
|
|
}
|
|
}
|
|
|
|
public async Task SaveAsync()
|
|
{
|
|
if (_isLoading || _taskId is null) return;
|
|
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var entity = await taskRepo.GetByIdAsync(_taskId);
|
|
if (entity is null) return;
|
|
|
|
entity.Title = Title;
|
|
entity.Description = Description;
|
|
entity.CommitType = CommitType;
|
|
entity.Model = ModelChoice != "(list default)"
|
|
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
|
: null;
|
|
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
|
entity.AgentPath = SelectedAgent?.Path;
|
|
|
|
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
|
entity.Status = status;
|
|
|
|
await taskRepo.UpdateAsync(entity);
|
|
StatusText = entity.Status.ToString().ToLowerInvariant();
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task AddTag()
|
|
{
|
|
var name = NewTagInput.Trim();
|
|
if (string.IsNullOrEmpty(name) || _taskId is null) return;
|
|
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var tagRepo = new TagRepository(context);
|
|
var taskRepo = new TaskRepository(context);
|
|
|
|
var tagId = await tagRepo.GetOrCreateAsync(name);
|
|
await taskRepo.AddTagAsync(_taskId, tagId);
|
|
|
|
Tags.Clear();
|
|
var tags = await taskRepo.GetTagsAsync(_taskId);
|
|
foreach (var tag in tags)
|
|
Tags.Add(tag);
|
|
|
|
NewTagInput = "";
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RemoveTag(TagEntity tag)
|
|
{
|
|
if (_taskId is null) return;
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
|
|
Tags.Remove(tag);
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task AddSubtask()
|
|
{
|
|
if (_taskId is null) return;
|
|
var entity = new SubtaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = _taskId,
|
|
Title = "",
|
|
Completed = false,
|
|
OrderNum = Subtasks.Count,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var subtaskRepo = new SubtaskRepository(context);
|
|
await subtaskRepo.AddAsync(entity);
|
|
var vm = SubtaskItemViewModel.From(entity);
|
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
|
Subtasks.Add(vm);
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
|
{
|
|
if (_taskId is null) return;
|
|
if (!string.IsNullOrEmpty(item.Id))
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var subtaskRepo = new SubtaskRepository(context);
|
|
await subtaskRepo.DeleteAsync(item.Id);
|
|
}
|
|
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
Subtasks.Remove(item);
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
|
|
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
|
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
|
try
|
|
{
|
|
if (_taskId is null) return;
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
|
|
var subtaskRepo = new SubtaskRepository(context);
|
|
await subtaskRepo.UpdateAsync(new SubtaskEntity
|
|
{
|
|
Id = vm.Id,
|
|
TaskId = _taskId,
|
|
Title = vm.Title,
|
|
Completed = vm.Completed,
|
|
OrderNum = Subtasks.IndexOf(vm),
|
|
CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
|
|
});
|
|
if (e.PropertyName == nameof(SubtaskItemViewModel.Completed))
|
|
TaskChanged?.Invoke(_taskId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// async void must never throw — surface via Debug.
|
|
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
|
|
}
|
|
}
|
|
|
|
public async Task RefreshSubtasksFromDbAsync()
|
|
{
|
|
if (_taskId is null) return;
|
|
|
|
List<SubtaskEntity> subtasks;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var subtaskRepo = new SubtaskRepository(context);
|
|
subtasks = await subtaskRepo.GetByTaskIdAsync(_taskId);
|
|
}
|
|
|
|
_isLoading = true;
|
|
try
|
|
{
|
|
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
Subtasks.Clear();
|
|
foreach (var s in subtasks)
|
|
{
|
|
var vm = SubtaskItemViewModel.From(s);
|
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
|
Subtasks.Add(vm);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isLoading = false;
|
|
}
|
|
}
|
|
|
|
public void SetAgentFromPath(string path)
|
|
{
|
|
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
|
if (existing is null)
|
|
{
|
|
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
|
AvailableAgents.Add(existing);
|
|
OnPropertyChanged(nameof(AvailableAgents));
|
|
}
|
|
SelectedAgent = existing;
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
|
_loadCts?.Cancel();
|
|
_loadCts?.Dispose();
|
|
_loadCts = null;
|
|
|
|
_taskId = null;
|
|
_listId = null;
|
|
Title = "";
|
|
Description = null;
|
|
Result = null;
|
|
LogPath = null;
|
|
StatusText = "";
|
|
HasWorktree = false;
|
|
LiveText = "";
|
|
_formatter = new StreamLineFormatter();
|
|
Tags.Clear();
|
|
NewTagInput = "";
|
|
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
|
Subtasks.Clear();
|
|
StatusChoice = "Manual";
|
|
CommitType = "chore";
|
|
ModelChoice = "(list default)";
|
|
SystemPromptOverride = null;
|
|
SelectedAgent = null;
|
|
}
|
|
|
|
private async Task LoadWorktreeAsync(string taskId)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var wtRepo = new WorktreeRepository(context);
|
|
var wt = await wtRepo.GetByTaskIdAsync(taskId);
|
|
HasWorktree = wt is not null;
|
|
if (wt is not null)
|
|
{
|
|
BranchName = wt.BranchName;
|
|
DiffStat = wt.DiffStat;
|
|
WorktreePath = wt.Path;
|
|
WorktreeState = wt.State.ToString().ToLowerInvariant();
|
|
}
|
|
else
|
|
{
|
|
BranchName = null;
|
|
DiffStat = null;
|
|
WorktreePath = null;
|
|
WorktreeState = "";
|
|
}
|
|
OnPropertyChanged(nameof(CanWorktreeAction));
|
|
}
|
|
|
|
public bool CanWorktreeAction => HasWorktree && WorktreeState == "active";
|
|
|
|
[RelayCommand]
|
|
private void OpenWorktree()
|
|
{
|
|
if (WorktreePath is null) return;
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = WorktreePath,
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ShowDiff()
|
|
{
|
|
if (WorktreePath is null) return;
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = "cmd.exe",
|
|
Arguments = $"/k git -C \"{WorktreePath}\" diff HEAD~1",
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.WriteLine($"Failed to show diff: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task MergeIntoMainAsync()
|
|
{
|
|
if (_taskId is null || _listId is null) return;
|
|
|
|
WorktreeEntity? wt;
|
|
ListEntity? list;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
var listRepo = new ListRepository(context);
|
|
list = await listRepo.GetByIdAsync(_listId);
|
|
}
|
|
if (wt is null || list?.WorkingDir is null) return;
|
|
|
|
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
|
|
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
|
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
|
|
}
|
|
await LoadWorktreeAsync(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task KeepAsBranchAsync()
|
|
{
|
|
if (_taskId is null || _listId is null) return;
|
|
|
|
WorktreeEntity? wt;
|
|
ListEntity? list;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
var listRepo = new ListRepository(context);
|
|
list = await listRepo.GetByIdAsync(_listId);
|
|
}
|
|
if (wt is null || list?.WorkingDir is null) return;
|
|
|
|
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
|
|
}
|
|
await LoadWorktreeAsync(_taskId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DiscardAsync()
|
|
{
|
|
if (_taskId is null || _listId is null) return;
|
|
|
|
WorktreeEntity? wt;
|
|
ListEntity? list;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
wt = await wtRepo.GetByTaskIdAsync(_taskId);
|
|
var listRepo = new ListRepository(context);
|
|
list = await listRepo.GetByIdAsync(_listId);
|
|
}
|
|
if (wt is null || list?.WorkingDir is null) return;
|
|
|
|
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
|
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
|
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var wtRepo = new WorktreeRepository(context);
|
|
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
|
|
}
|
|
await LoadWorktreeAsync(_taskId);
|
|
}
|
|
|
|
private void OnTaskMessage(string taskId, string line)
|
|
{
|
|
if (taskId != _taskId) return;
|
|
var formatted = _formatter.FormatLine(line);
|
|
if (formatted is not null)
|
|
{
|
|
LiveText += formatted;
|
|
if (LiveText.Length > 50_000)
|
|
LiveText = StreamLineFormatter.Trim(LiveText);
|
|
}
|
|
}
|
|
|
|
private void OnRunNowRequested(string taskId)
|
|
{
|
|
if (taskId != _taskId) return;
|
|
StatusText = "starting...";
|
|
LiveText = "";
|
|
_formatter = new StreamLineFormatter();
|
|
}
|
|
|
|
private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
|
|
{
|
|
if (taskId != _taskId) return;
|
|
StatusText = "running";
|
|
}
|
|
|
|
private async void OnWorktreeUpdated(string taskId)
|
|
{
|
|
if (taskId != _taskId) return;
|
|
try
|
|
{
|
|
await LoadWorktreeAsync(taskId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// async void must never throw.
|
|
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
|
|
}
|
|
}
|
|
|
|
private async void OnTaskUpdated(string taskId)
|
|
{
|
|
if (taskId != _taskId) return;
|
|
try
|
|
{
|
|
await LoadAsync(taskId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// async void must never throw.
|
|
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
|
}
|
|
}
|
|
}
|