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 _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 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 Tags { get; } = new(); [ObservableProperty] private string _newTagInput = ""; public ObservableCollection 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? TaskChanged; public TaskDetailViewModel(IDbContextFactory 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 tags; List 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(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 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}"); } } }