Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
Mika Kuns 2a1f26d817 fix(ui): fix live output visibility and editor dialog graying out
- Remove wrapping ScrollViewer from live output TextBox — Avalonia
  TextBox with AcceptsReturn handles its own scrolling; nested
  ScrollViewer caused layout collapse
- Auto-scroll via CaretIndex instead of removed ScrollViewer
- Add OnWindowClosed to both editor ViewModels, ensuring the
  TaskCompletionSource always resolves when the dialog closes
  (including via X button), preventing RelayCommand from staying
  permanently disabled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:01:08 +02:00

303 lines
9.5 KiB
C#

using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskListViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly TagRepository _tagRepo;
private readonly ListRepository _listRepo;
private readonly WorkerClient _worker;
private readonly Func<TaskEditorViewModel> _editorFactory;
private readonly Action<string> _showMessage;
public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
[ObservableProperty] private TaskItemViewModel? _selectedTask;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddTaskCommand))] private string? _currentListId;
[ObservableProperty] private string _listName = "Tasks";
[ObservableProperty] private string _inlineAddTitle = "";
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
SelectedTaskChanged?.Invoke(value);
public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo,
ListRepository listRepo, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
{
_taskRepo = taskRepo;
_tagRepo = tagRepo;
_listRepo = listRepo;
_worker = worker;
_editorFactory = editorFactory;
_showMessage = showMessage;
worker.TaskUpdatedEvent += OnTaskUpdated;
worker.TaskFinishedEvent += (_, taskId, _, _) => OnTaskUpdated(taskId);
worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
foreach (var t in Tasks)
t.RunNowCommand.NotifyCanExecuteChanged();
});
};
worker.RunNowRequestedEvent += taskId =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.SetStarting();
};
worker.TaskStartedEvent += (_, taskId, _) =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.ClearStarting();
};
}
public async Task LoadAsync(string? listId)
{
CurrentListId = listId;
Tasks.Clear();
SelectedTask = null;
if (listId is not null)
{
var list = await _listRepo.GetByIdAsync(listId);
ListName = list?.Name ?? "Tasks";
}
else
{
ListName = "Tasks";
}
if (listId is null) return;
try
{
var entities = await _taskRepo.GetByListAsync(listId);
foreach (var e in entities)
{
var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
}
}
catch (Exception ex)
{
_showMessage($"Error loading tasks: {ex.Message}");
}
}
private bool CanAddTask() => CurrentListId is not null;
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task InlineAdd()
{
var title = InlineAddTitle.Trim();
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
var list = await _listRepo.GetByIdAsync(CurrentListId);
var defaultCommitType = list?.DefaultCommitType ?? "chore";
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = CurrentListId,
Title = title,
Status = TaskStatus.Manual,
CommitType = defaultCommitType,
CreatedAt = DateTime.UtcNow,
};
try
{
await _taskRepo.AddAsync(entity);
var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
}
catch (Exception ex)
{
_showMessage($"Error creating task: {ex.Message}");
}
}
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
// Get list default commit type
var list = await _listRepo.GetByIdAsync(CurrentListId);
var defaultCommitType = list?.DefaultCommitType ?? "chore";
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
if (saved is null) return;
try
{
await _taskRepo.AddAsync(saved);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
await _taskRepo.AddTagAsync(saved.Id, tagId);
}
var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
// Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued &&
tags.Any(t => t.Name == "agent"))
{
try { await _worker.WakeQueueAsync(); }
catch { /* worker offline is fine */ }
}
}
catch (Exception ex)
{
_showMessage($"Error creating task: {ex.Message}");
}
}
[RelayCommand]
private async Task EditTask()
{
if (SelectedTask is null || CurrentListId is null) return;
var entity = await _taskRepo.GetByIdAsync(SelectedTask.Id);
if (entity is null) return;
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, taskTags);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
if (saved is null) return;
try
{
await _taskRepo.UpdateAsync(saved);
var existingTags = await _taskRepo.GetTagsAsync(saved.Id);
foreach (var old in existingTags)
await _taskRepo.RemoveTagAsync(saved.Id, old.Id);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
await _taskRepo.AddTagAsync(saved.Id, tagId);
}
var newTags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
SelectedTask.Refresh(saved, newTags);
}
catch (Exception ex)
{
_showMessage($"Error updating task: {ex.Message}");
}
}
[RelayCommand]
private async Task DeleteTask()
{
if (SelectedTask is null) return;
try
{
await _taskRepo.DeleteAsync(SelectedTask.Id);
Tasks.Remove(SelectedTask);
SelectedTask = null;
}
catch (Exception ex)
{
_showMessage($"Error deleting task: {ex.Message}");
}
}
public async Task RefreshSingleAsync(string taskId)
{
var entity = await _taskRepo.GetByIdAsync(taskId);
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
if (entity is null)
{
if (existing is not null) Tasks.Remove(existing);
return;
}
var tags = await _taskRepo.GetEffectiveTagsAsync(taskId);
if (existing is not null)
existing.Refresh(entity, tags);
}
private async Task RunNowAsync(string taskId)
{
try
{
await _worker.RunNowAsync(taskId);
}
catch (Exception ex)
{
_showMessage($"RunNow failed: {ex.Message}");
}
}
private async Task ToggleDoneAsync(string taskId)
{
var entity = await _taskRepo.GetByIdAsync(taskId);
if (entity is null) return;
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
if (entity.Status == TaskStatus.Done)
entity.FinishedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(entity);
await RefreshSingleAsync(taskId);
}
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
await RefreshSingleAsync(taskId);
}
private static async Task ShowDialogAsync(Window dialog)
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
&& desktop.MainWindow is not null)
{
await dialog.ShowDialog(desktop.MainWindow);
}
else
{
dialog.Show();
}
}
}