chore(ui): remove obsolete pre-rewrite views and viewmodels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-20 10:41:14 +02:00
parent 6dade011b0
commit 5a17a727b9
20 changed files with 0 additions and 2754 deletions

View File

@@ -1,125 +0,0 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
namespace ClaudeDo.Ui.ViewModels;
public partial class ListEditorViewModel : ViewModelBase
{
[ObservableProperty] private string _name = "";
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = "chore";
[ObservableProperty] private string _windowTitle = "New List";
// Config fields
[ObservableProperty] private string _model = "Sonnet";
[ObservableProperty] private string? _systemPrompt;
[ObservableProperty] private AgentInfo? _selectedAgent;
private string? _editId;
private DateTime _createdAt;
private TaskCompletionSource<ListEntity?> _tcs = new();
public event Action? RequestClose;
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
private static readonly Dictionary<string, string> ModelToId = new()
{
["Sonnet"] = "claude-sonnet-4-6",
["Opus"] = "claude-opus-4-6",
["Haiku"] = "claude-haiku-4-5",
};
private static readonly Dictionary<string, string> IdToModel =
ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
public static string ModelIdToDisplay(string? modelId) =>
modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
public static string? ModelDisplayToId(string display) =>
ModelToId.TryGetValue(display, out var id) ? id : null;
public List<AgentInfo> AvailableAgents { get; set; } = [];
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
public void InitForCreate()
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = null;
_createdAt = DateTime.UtcNow;
WindowTitle = "New List";
}
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = entity.Id;
_createdAt = entity.CreatedAt;
Name = entity.Name;
WorkingDir = entity.WorkingDir;
DefaultCommitType = entity.DefaultCommitType;
WindowTitle = $"Edit List: {entity.Name}";
if (config is not null)
{
Model = ModelIdToDisplay(config.Model);
SystemPrompt = config.SystemPrompt;
SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config.AgentPath);
}
}
public ListConfigEntity? BuildConfig(string listId)
{
var modelId = ModelDisplayToId(Model);
if (modelId is null && SystemPrompt is null && SelectedAgent is null)
return null;
return new ListConfigEntity
{
ListId = listId,
Model = modelId,
SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
AgentPath = SelectedAgent?.Path,
};
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Name)) return;
var entity = new ListEntity
{
Id = _editId ?? Guid.NewGuid().ToString(),
Name = Name.Trim(),
CreatedAt = _createdAt,
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir.Trim(),
DefaultCommitType = DefaultCommitType,
};
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_tcs.TrySetResult(null);
RequestClose?.Invoke();
}
public void OnWindowClosed()
{
_tcs.TrySetResult(null);
}
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

@@ -1,43 +0,0 @@
using System;
using Avalonia.Media;
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class ListItemViewModel : ViewModelBase
{
[ObservableProperty] private string _name;
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType;
private static readonly IBrush[] DotPalette =
[
new SolidColorBrush(Color.Parse("#3d9474")), // green
new SolidColorBrush(Color.Parse("#5571a1")), // blue
new SolidColorBrush(Color.Parse("#d4964a")), // amber
new SolidColorBrush(Color.Parse("#7c6aad")), // purple
new SolidColorBrush(Color.Parse("#c25d6a")), // rose
];
public IBrush DotBrush => DotPalette[Math.Abs(Id.GetHashCode()) % DotPalette.Length];
public string Id { get; }
public ListItemViewModel(ListEntity entity)
{
Id = entity.Id;
_name = entity.Name;
_workingDir = entity.WorkingDir;
_defaultCommitType = entity.DefaultCommitType;
}
public ListEntity ToEntity() => new()
{
Id = Id,
Name = Name,
CreatedAt = DateTime.MinValue, // not used for update
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType = DefaultCommitType,
};
}

View File

@@ -1,232 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
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 Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<ListEditorViewModel> _listEditorFactory;
public ObservableCollection<ListItemViewModel> Lists { get; } = new();
[ObservableProperty] private ListItemViewModel? _selectedList;
public TaskListViewModel TaskList { get; }
public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
private readonly Action<string> _onTaskSubtasksChanged;
public MainWindowViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker,
TaskListViewModel taskList,
TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar,
Func<ListEditorViewModel> listEditorFactory)
{
_dbFactory = dbFactory;
_worker = worker;
_listEditorFactory = listEditorFactory;
TaskList = taskList;
TaskDetail = taskDetail;
StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
_onTaskSubtasksChanged = taskId =>
{
if (TaskDetail.CurrentTaskId == taskId)
_ = TaskDetail.RefreshSubtasksFromDbAsync();
};
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
TaskList.TaskSubtasksChanged += _onTaskSubtasksChanged;
TaskDetail.TaskChanged += _onTaskChanged;
}
public void Dispose()
{
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
TaskList.TaskSubtasksChanged -= _onTaskSubtasksChanged;
TaskDetail.TaskChanged -= _onTaskChanged;
}
public async Task InitializeAsync()
{
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var lists = await listRepo.GetAllAsync();
foreach (var l in lists)
Lists.Add(new ListItemViewModel(l));
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
}
_ = _worker.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted)
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
}, TaskScheduler.Default);
}
partial void OnSelectedListChanged(ListItemViewModel? value)
{
_ = TaskList.LoadAsync(value?.Id);
TaskDetail.Clear();
}
private async void OnSelectedTaskChanged(TaskItemViewModel? task)
{
if (task is null)
TaskDetail.Clear();
else
await TaskDetail.LoadAsync(task.Id);
}
[RelayCommand]
private async Task AddList()
{
var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate();
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.AddAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity));
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error creating list: {ex.Message}");
}
}
[RelayCommand]
private async Task EditList()
{
if (SelectedList is null) return;
ListEntity? existing;
ListConfigEntity? config;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
existing = await listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
config = await listRepo.GetConfigAsync(existing.Id);
}
var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(existing, config);
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.UpdateAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error updating list: {ex.Message}");
}
}
[ObservableProperty] private bool _isDeleteConfirmVisible;
private ListItemViewModel? _pendingDeleteList;
[RelayCommand]
private void DeleteList()
{
if (SelectedList is null) return;
_pendingDeleteList = SelectedList;
IsDeleteConfirmVisible = true;
}
[RelayCommand]
private async Task ConfirmDeleteList()
{
IsDeleteConfirmVisible = false;
if (_pendingDeleteList is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(_pendingDeleteList.Id);
Lists.Remove(_pendingDeleteList);
if (SelectedList == _pendingDeleteList)
SelectedList = null;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
}
finally
{
_pendingDeleteList = null;
}
}
[RelayCommand]
private void CancelDeleteList()
{
IsDeleteConfirmVisible = false;
_pendingDeleteList = null;
}
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();
}
}
}

View File

@@ -1,55 +0,0 @@
using System.Collections.Specialized;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class StatusBarViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
[ObservableProperty] private string _connectionStatus = "Offline";
[ObservableProperty] private string _activeTasksSummary = "";
[ObservableProperty] private string _statusMessage = "";
public StatusBarViewModel(WorkerClient worker)
{
_worker = worker;
worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected) ||
e.PropertyName == nameof(WorkerClient.IsReconnecting))
{
ConnectionStatus = worker.IsConnected ? "Online"
: worker.IsReconnecting ? "Connecting..."
: "Offline";
}
};
worker.ActiveTasks.CollectionChanged += OnActiveTasksChanged;
RefreshActiveSummary();
}
private void OnActiveTasksChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
RefreshActiveSummary();
private void RefreshActiveSummary()
{
if (_worker.ActiveTasks.Count == 0)
{
ActiveTasksSummary = "";
return;
}
var parts = _worker.ActiveTasks
.Select(t => $"{t.Slot}: {Shorten(t.TaskId)}")
.ToList();
ActiveTasksSummary = string.Join(" | ", parts);
}
private static string Shorten(string id) =>
id.Length > 8 ? id[..8] : id;
public void ShowMessage(string msg) => StatusMessage = msg;
}

View File

@@ -1,23 +0,0 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class SubtaskItemViewModel : ObservableObject
{
[ObservableProperty] private string _title = string.Empty;
[ObservableProperty] private bool _completed;
public string Id { get; set; } = string.Empty;
public string? OriginalTitle { get; set; }
public bool OriginalCompleted { get; set; }
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
{
Id = e.Id,
Title = e.Title,
Completed = e.Completed,
OriginalTitle = e.Title,
OriginalCompleted = e.Completed,
};
}

View File

@@ -1,583 +0,0 @@
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}");
}
}
}

View File

@@ -1,264 +0,0 @@
using System.Collections.ObjectModel;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
[ObservableProperty] private string _commitType = "chore";
[ObservableProperty] private string _statusChoice = "manual";
[ObservableProperty] private string _tagsInput = "";
[ObservableProperty] private string _windowTitle = "New Task";
[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; set; } = [];
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _editId;
private string _listId = "";
private DateTime _createdAt;
private TaskCompletionSource<TaskEntity?> _tcs = new();
public event Action? RequestClose;
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] StatusChoices { get; } =
["manual", "queued"];
public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
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 IReadOnlyList<string> SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
.ToList();
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null;
_listId = listId;
_createdAt = DateTime.UtcNow;
CommitType = defaultCommitType;
WindowTitle = "New Task";
Subtasks.Clear();
}
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
Title = entity.Title;
Description = entity.Description;
CommitType = entity.CommitType;
StatusChoice = entity.Status switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
if (entity.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}";
Subtasks.Clear();
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
foreach (var s in list)
Subtasks.Add(SubtaskItemViewModel.From(s));
}
// Keep old sync overload for callers that haven't loaded agents yet
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
Title = entity.Title;
Description = entity.Description;
CommitType = entity.CommitType;
StatusChoice = entity.Status switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
if (entity.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}";
}
[RelayCommand]
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
[RelayCommand]
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
[RelayCommand]
private async Task Save()
{
if (string.IsNullOrWhiteSpace(Title)) return;
var status = StatusChoice switch
{
"queued" => TaskStatus.Queued,
_ => TaskStatus.Manual,
};
var taskId = _editId ?? Guid.NewGuid().ToString();
var entity = new TaskEntity
{
Id = taskId,
ListId = _listId,
Title = Title.Trim(),
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
Status = status,
CommitType = CommitType,
CreatedAt = _createdAt,
};
entity.Model = ModelChoice != "(list default)"
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
: null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
// Persist subtask changes
if (_editId is not null)
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var existing = await subtaskRepo.GetByTaskIdAsync(taskId);
var existingIds = existing.Select(s => s.Id).ToHashSet();
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
// Deleted
foreach (var id in existingIds.Except(currentIds))
await subtaskRepo.DeleteAsync(id);
// Updated
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
{
if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
{
var origSub = existing.FirstOrDefault(e => e.Id == vm.Id);
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = origSub?.CreatedAt ?? DateTime.UtcNow });
}
else
{
// update order_num if position changed
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
if (orig is not null && orig.OrderNum != idx)
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
}
}
}
// Added (id == "" means new)
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
}
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_tcs.TrySetResult(null);
RequestClose?.Invoke();
}
public void OnWindowClosed()
{
_tcs.TrySetResult(null);
}
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

@@ -1,179 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Media;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskItemViewModel : ViewModelBase
{
[ObservableProperty] private string _title;
[ObservableProperty] private string _statusText;
[ObservableProperty] private string _tagsText;
[ObservableProperty] private string _commitType;
[ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting;
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _hasSubtasks;
[ObservableProperty] private int _subtaskCount;
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
public string Id { get; }
public string ListId { get; }
public TaskEntity Entity { get; private set; }
private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone;
private readonly Action<string>? _onSubtasksChanged;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? toggleDone = null,
Action<string>? onSubtasksChanged = null)
{
Entity = entity;
Id = entity.Id;
ListId = entity.ListId;
_title = entity.Title;
_status = entity.Status;
_statusText = entity.Status.ToString().ToLowerInvariant();
_tagsText = string.Join(", ", tags.Select(t => t.Name));
_commitType = entity.CommitType;
_description = entity.Description;
_runNow = runNow;
_canRunNow = canRunNow;
_toggleDone = toggleDone;
_onSubtasksChanged = onSubtasksChanged;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
}
public bool IsDone => Status == TaskStatus.Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool CanToggleDone => Status != TaskStatus.Running && Status != TaskStatus.Failed;
public TextDecorationCollection? TitleDecorations => IsDone
? TextDecorations.Strikethrough
: null;
public IBrush TitleForeground => IsDone
? new SolidColorBrush(Color.Parse("#5a6578"))
: new SolidColorBrush(Color.Parse("#e2e8f0"));
public double RowOpacity => IsDone ? 0.6 : 1.0;
public void Refresh(TaskEntity entity, IReadOnlyList<TagEntity> tags)
{
Entity = entity;
Title = entity.Title;
Status = entity.Status;
StatusText = entity.Status.ToString().ToLowerInvariant();
TagsText = string.Join(", ", tags.Select(t => t.Name));
CommitType = entity.CommitType;
Description = entity.Description;
RunNowCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
IsStarting = false;
OnPropertyChanged(nameof(CanToggleDone));
OnPropertyChanged(nameof(TitleDecorations));
OnPropertyChanged(nameof(TitleForeground));
OnPropertyChanged(nameof(RowOpacity));
ToggleDoneCommand.NotifyCanExecuteChanged();
}
public void SetStarting()
{
IsStarting = true;
StatusText = "starting...";
RunNowCommand.NotifyCanExecuteChanged();
}
public void ClearStarting()
{
IsStarting = false;
RunNowCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanRunNow))]
private async Task RunNowAsync()
{
if (_runNow is not null)
await _runNow(Id);
}
private bool CanRunNow() =>
_canRunNow() && Status != TaskStatus.Running && !IsStarting;
[RelayCommand(CanExecute = nameof(CanToggleDone))]
private async Task ToggleDone()
{
if (_toggleDone is not null)
await _toggleDone(Id);
}
[RelayCommand]
private async Task ToggleExpanded()
{
IsExpanded = !IsExpanded;
if (IsExpanded && !_subtasksLoaded)
await LoadSubtasksAsync();
}
private async Task LoadSubtasksAsync()
{
using var context = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(context);
var entities = await repo.GetByTaskIdAsync(Id);
Subtasks.Clear();
foreach (var e in entities)
Subtasks.Add(SubtaskItemViewModel.From(e));
_subtasksLoaded = true;
}
[RelayCommand]
private async Task ToggleSubtaskDone(string subtaskId)
{
var vm = Subtasks.FirstOrDefault(s => s.Id == subtaskId);
if (vm is null) return;
vm.Completed = !vm.Completed;
using var context = _dbFactory.CreateDbContext();
var entity = await context.Subtasks.FindAsync(subtaskId);
if (entity is not null)
{
entity.Completed = vm.Completed;
await context.SaveChangesAsync();
}
_onSubtasksChanged?.Invoke(Id);
}
public async Task RefreshSubtasksAsync(int newCount)
{
SubtaskCount = newCount;
HasSubtasks = newCount > 0;
if (!HasSubtasks)
{
IsExpanded = false;
Subtasks.Clear();
_subtasksLoaded = false;
}
else if (_subtasksLoaded || IsExpanded)
{
await LoadSubtasksAsync();
}
}
}

View File

@@ -1,360 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
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 Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskListViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
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;
public event Action<string>? TaskSubtasksChanged;
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
SelectedTaskChanged?.Invoke(value);
private void NotifySubtasksChanged(string taskId) =>
TaskSubtasksChanged?.Invoke(taskId);
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
{
_dbFactory = dbFactory;
_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)
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
ListName = list?.Name ?? "Tasks";
}
else
{
ListName = "Tasks";
}
if (listId is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entities = await taskRepo.GetByListIdAsync(listId);
var taskIds = entities.Select(e => e.Id).ToList();
var subtaskCounts = await context.Subtasks
.Where(s => taskIds.Contains(s.TaskId))
.GroupBy(s => s.TaskId)
.ToDictionaryAsync(g => g.Key, g => g.Count());
foreach (var e in entities)
{
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
subtaskCounts.TryGetValue(e.Id, out var count);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, count, ToggleDoneAsync, NotifySubtasksChanged));
}
}
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;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(CurrentListId);
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
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync, NotifySubtasksChanged);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
}
catch (Exception ex)
{
_showMessage($"Error creating task: {ex.Message}");
}
}
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(listId, 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
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
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,
_dbFactory, 0, ToggleDoneAsync, NotifySubtasksChanged));
// 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;
TaskEntity? entity;
List<TagEntity> taskTags;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
entity = await taskRepo.GetByIdAsync(SelectedTask.Id);
if (entity is null) return;
taskTags = await taskRepo.GetTagsAsync(entity.Id);
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
await editor.InitForEditAsync(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
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
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
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
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)
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
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);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
}
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)
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
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;
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
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();
}
}
}