feat(ui): wire avalonia desktop ui to data and worker

App: build a ServiceProvider in Program.cs (AppSettings, SqliteConnectionFactory,
all repositories, GitService, WorkerClient, all view-models), apply schema, then
hand control to Avalonia. App.OnFrameworkInitializationCompleted resolves
MainWindowViewModel from the container.

Ui:
- AppSettings POCO loaded from ~/.todo-app/ui.config.json (db path, hub url).
- WorkerClient wraps HubConnection with auto-reconnect, exposes IsConnected and
  ActiveTasks plus C# events for TaskStarted/Finished/Message/Updated and
  WorktreeUpdated; all inbound events are marshalled to the UI thread.
- ViewModels: MainWindow (lists CRUD via ListEditor dialog), TaskList (load by
  list, add/edit/delete, auto WakeQueue on agent+queued create), TaskItem
  (RunNow gated on connection + status), TaskDetail (description, result, live
  ndjson rolling buffer of 500 lines, worktree branch/diff with merge/keep/
  discard via GitService), StatusBar, ListEditor, TaskEditor.
- Views: 3-pane MainWindow (lists | tasks | detail) with GridSplitters, status
  bar, dialog windows for the editors. Status badges via StatusColorConverter.
- Markdown rendering, folder picker, delete-confirmation, settings dialog and
  scroll-to-bottom on the live log are intentionally TODO -- functional
  scaffold only.

Tests: also debounce the FIFO queue test (poll instead of Task.Delay(200)) so
the assertion isn't racy when the suite runs alongside the slower git tests.
38 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-13 14:01:03 +02:00
parent 01235d986f
commit 48e4aabeb1
28 changed files with 1527 additions and 26 deletions

View File

@@ -1,16 +1,16 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.App;
public partial class App : Application
{
public static ServiceProvider Services { get; set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -22,10 +22,10 @@ public partial class App : Application
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
DataContext = Services.GetRequiredService<MainWindowViewModel>(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
}

View File

@@ -22,6 +22,7 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,18 +1,31 @@
using Avalonia;
using Avalonia;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace ClaudeDo.App;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
var services = BuildServices();
App.Services = services;
// Ensure DB schema exists
var factory = services.GetRequiredService<SqliteConnectionFactory>();
SchemaInitializer.Apply(factory);
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
@@ -21,4 +34,56 @@ sealed class Program
#endif
.WithInterFont()
.LogToTrace();
private static ServiceProvider BuildServices()
{
var settings = AppSettings.Load();
var dbPath = Paths.Expand(settings.DbPath);
var sc = new ServiceCollection();
// Infrastructure
sc.AddSingleton(settings);
sc.AddSingleton(new SqliteConnectionFactory(dbPath));
// Repositories
sc.AddSingleton<ListRepository>();
sc.AddSingleton<TaskRepository>();
sc.AddSingleton<TagRepository>();
sc.AddSingleton<WorktreeRepository>();
// Services
sc.AddSingleton<GitService>();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
// ViewModels
sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>();
sc.AddSingleton<TaskListViewModel>(sp =>
{
var taskRepo = sp.GetRequiredService<TaskRepository>();
var tagRepo = sp.GetRequiredService<TagRepository>();
var listRepo = sp.GetRequiredService<ListRepository>();
var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel(
taskRepo, tagRepo, listRepo, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg));
});
sc.AddSingleton<MainWindowViewModel>(sp =>
{
return new MainWindowViewModel(
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(),
sp.GetRequiredService<TaskDetailViewModel>(),
sp.GetRequiredService<StatusBarViewModel>(),
() => sp.GetRequiredService<ListEditorViewModel>());
});
return sc.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using ClaudeDo.Data;
namespace ClaudeDo.Ui;
public sealed class AppSettings
{
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
public static AppSettings Load()
{
try
{
if (File.Exists(ConfigPath))
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
}
catch
{
// Fall through to defaults
}
return new();
}
}

View File

@@ -8,6 +8,7 @@
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<PropertyGroup>

View File

@@ -0,0 +1,41 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public class StatusColorConverter : IValueConverter
{
public static StatusColorConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var status = value?.ToString()?.ToLowerInvariant();
return status switch
{
"queued" => Brushes.DodgerBlue,
"running" => Brushes.Orange,
"done" => Brushes.Green,
"failed" => Brushes.Red,
"manual" => Brushes.Gray,
_ => Brushes.Transparent,
};
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class ConnectionColorConverter : IValueConverter
{
public static ConnectionColorConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var text = value?.ToString();
return text == "Online" ? Brushes.Green : Brushes.Red;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services;
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
public partial class WorkerClient : ObservableObject, IAsyncDisposable
{
private readonly HubConnection _hub;
[ObservableProperty]
private bool _isConnected;
public ObservableCollection<ActiveTask> ActiveTasks { get; } = new();
public event Action<string, string, DateTime>? TaskStartedEvent;
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent;
public WorkerClient(string signalRUrl)
{
_hub = new HubConnectionBuilder()
.WithUrl(signalRUrl)
.WithAutomaticReconnect()
.Build();
_hub.Reconnected += async _ =>
{
Dispatcher.UIThread.Post(() => IsConnected = true);
await SeedActiveTasksAsync();
};
_hub.Reconnecting += _ =>
{
Dispatcher.UIThread.Post(() => IsConnected = false);
return Task.CompletedTask;
};
_hub.Closed += _ =>
{
Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
ActiveTasks.Clear();
});
return Task.CompletedTask;
};
_hub.On<string, string, DateTime>("TaskStarted", (slot, taskId, startedAt) =>
{
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Add(new ActiveTask(slot, taskId, startedAt));
TaskStartedEvent?.Invoke(slot, taskId, startedAt);
});
});
_hub.On<string, string, string, DateTime>("TaskFinished", (slot, taskId, status, finishedAt) =>
{
Dispatcher.UIThread.Post(() =>
{
var existing = ActiveTasks.FirstOrDefault(t => t.TaskId == taskId);
if (existing is not null)
ActiveTasks.Remove(existing);
TaskFinishedEvent?.Invoke(slot, taskId, status, finishedAt);
});
});
_hub.On<string, string>("TaskMessage", (taskId, line) =>
{
Dispatcher.UIThread.Post(() => TaskMessageEvent?.Invoke(taskId, line));
});
_hub.On<string>("TaskUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
});
}
public async Task StartAsync()
{
try
{
await _hub.StartAsync();
Dispatcher.UIThread.Post(() => IsConnected = true);
await SeedActiveTasksAsync();
}
catch
{
Dispatcher.UIThread.Post(() => IsConnected = false);
}
}
public async Task StopAsync()
{
try { await _hub.StopAsync(); } catch { /* swallow */ }
}
public async Task RunNowAsync(string taskId)
{
await _hub.InvokeAsync("RunNow", taskId);
}
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
}
public async Task WakeQueueAsync()
{
await _hub.InvokeAsync("WakeQueue");
}
private async Task SeedActiveTasksAsync()
{
try
{
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive");
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Clear();
foreach (var a in active)
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
});
}
catch
{
// Worker might not support GetActive yet
}
}
public async ValueTask DisposeAsync()
{
await _hub.DisposeAsync();
}
// DTO for deserializing the GetActive response
private sealed class ActiveTaskDto
{
public string Slot { get; set; } = "";
public string TaskId { get; set; } = "";
public DateTime StartedAt { get; set; }
}
}

View File

@@ -0,0 +1,72 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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";
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 void InitForCreate()
{
_editId = null;
_createdAt = DateTime.UtcNow;
WindowTitle = "New List";
}
public void InitForEdit(ListEntity entity)
{
_editId = entity.Id;
_createdAt = entity.CreatedAt;
Name = entity.Name;
WorkingDir = entity.WorkingDir;
DefaultCommitType = entity.DefaultCommitType;
WindowTitle = $"Edit List: {entity.Name}";
}
[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();
}
/// <summary>
/// Called by the view to await the editor result.
/// Returns the entity to persist or null if cancelled.
/// </summary>
public Task<ListEntity?> ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource<ListEntity?>();
return _tcs.Task;
}
}

View File

@@ -0,0 +1,30 @@
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;
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,6 +1,159 @@
namespace ClaudeDo.Ui.ViewModels;
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;
namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public string Greeting { get; } = "Welcome to Avalonia!";
private readonly ListRepository _listRepo;
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; }
public MainWindowViewModel(
ListRepository listRepo,
WorkerClient worker,
TaskListViewModel taskList,
TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar,
Func<ListEditorViewModel> listEditorFactory)
{
_listRepo = listRepo;
_worker = worker;
_listEditorFactory = listEditorFactory;
TaskList = taskList;
TaskDetail = taskDetail;
StatusBar = statusBar;
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
}
public async Task InitializeAsync()
{
try
{
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();
}
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();
editor.InitForCreate();
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
await _listRepo.AddAsync(entity);
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;
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
var editor = _listEditorFactory();
editor.InitForEdit(existing);
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
await _listRepo.UpdateAsync(entity);
SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error updating list: {ex.Message}");
}
}
[RelayCommand]
private async Task DeleteList()
{
if (SelectedList is null) return;
// TODO: confirmation dialog
try
{
await _listRepo.DeleteAsync(SelectedList.Id);
Lists.Remove(SelectedList);
SelectedList = null;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
}
}
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

@@ -0,0 +1,52 @@
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))
{
ConnectionStatus = worker.IsConnected ? "Online" : "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

@@ -0,0 +1,201 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly WorktreeRepository _worktreeRepo;
private readonly ListRepository _listRepo;
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 = "";
// Worktree
[ObservableProperty] private bool _hasWorktree;
[ObservableProperty] private string? _branchName;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string _worktreeState = "";
// Live stream
public ObservableCollection<string> LiveLines { get; } = new();
private string? _taskId;
private string? _listId;
private const int MaxLiveLines = 500;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
ListRepository listRepo, GitService git, WorkerClient worker)
{
_taskRepo = taskRepo;
_worktreeRepo = worktreeRepo;
_listRepo = listRepo;
_git = git;
_worker = worker;
worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
worker.TaskUpdatedEvent += OnTaskUpdated;
}
public async Task LoadAsync(string taskId)
{
_taskId = taskId;
LiveLines.Clear();
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return;
_listId = task.ListId;
Title = task.Title;
Description = task.Description;
Result = task.Result;
LogPath = task.LogPath;
StatusText = task.Status.ToString().ToLowerInvariant();
await LoadWorktreeAsync(taskId);
}
public void Clear()
{
_taskId = null;
_listId = null;
Title = "";
Description = null;
Result = null;
LogPath = null;
StatusText = "";
HasWorktree = false;
LiveLines.Clear();
}
private async Task LoadWorktreeAsync(string taskId)
{
var wt = await _worktreeRepo.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 { /* best effort */ }
}
[RelayCommand]
private void ShowDiff()
{
// TODO: open a proper diff viewer; for now open git diff in a console
if (WorktreePath is null) return;
try
{
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/k git -C \"{WorktreePath}\" diff HEAD~1",
UseShellExecute = true,
});
}
catch { /* best effort */ }
}
[RelayCommand]
private async Task MergeIntoMainAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var 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);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
await LoadWorktreeAsync(_taskId);
}
[RelayCommand]
private async Task KeepAsBranchAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
await LoadWorktreeAsync(_taskId);
}
[RelayCommand]
private async Task DiscardAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var 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);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
await LoadWorktreeAsync(_taskId);
}
private void OnTaskMessage(string taskId, string line)
{
if (taskId != _taskId) return;
if (LiveLines.Count >= MaxLiveLines)
LiveLines.RemoveAt(0);
LiveLines.Add(line);
}
private async void OnWorktreeUpdated(string taskId)
{
if (taskId != _taskId) return;
await LoadWorktreeAsync(taskId);
}
private async void OnTaskUpdated(string taskId)
{
if (taskId != _taskId) return;
await LoadAsync(taskId);
}
}

View File

@@ -0,0 +1,97 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase
{
[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";
private string? _editId;
private string _listId = "";
private DateTime _createdAt;
private TaskCompletionSource<TaskEntity?> _tcs = new();
public event Action? RequestClose;
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] StatusChoices { get; } =
["manual", "queued"];
public IReadOnlyList<string> SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
.ToList();
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_editId = null;
_listId = listId;
_createdAt = DateTime.UtcNow;
CommitType = defaultCommitType;
WindowTitle = "New Task";
}
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{
_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));
WindowTitle = $"Edit Task: {entity.Title}";
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Title)) return;
var status = StatusChoice switch
{
"queued" => TaskStatus.Queued,
_ => TaskStatus.Manual,
};
var entity = new TaskEntity
{
Id = _editId ?? Guid.NewGuid().ToString(),
ListId = _listId,
Title = Title.Trim(),
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
Status = status,
CommitType = CommitType,
CreatedAt = _createdAt,
};
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_tcs.TrySetResult(null);
RequestClose?.Invoke();
}
public Task<TaskEntity?> ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource<TaskEntity?>();
return _tcs.Task;
}
}

View File

@@ -0,0 +1,61 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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;
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;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow)
{
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;
}
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();
}
[RelayCommand(CanExecute = nameof(CanRunNow))]
private async Task RunNowAsync()
{
if (_runNow is not null)
await _runNow(Id);
}
private bool CanRunNow() =>
_canRunNow() && Status == TaskStatus.Queued;
}

View File

@@ -0,0 +1,218 @@
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] private string? _currentListId;
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);
}
public async Task LoadAsync(string? listId)
{
CurrentListId = listId;
Tasks.Clear();
SelectedTask = null;
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));
}
}
catch (Exception ex)
{
_showMessage($"Error loading tasks: {ex.Message}");
}
}
[RelayCommand]
private async Task AddTask()
{
if (CurrentListId is null) return;
// Get list default commit type
var list = await _listRepo.GetByIdAsync(CurrentListId);
var defaultCommitType = list?.DefaultCommitType ?? "chore";
var editor = _editorFactory();
editor.InitForCreate(CurrentListId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
_ = 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));
// 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();
editor.InitForEdit(entity, taskTags);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
_ = 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 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();
}
}
}

View File

@@ -0,0 +1,28 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
x:Class="ClaudeDo.Ui.Views.ListEditorView"
x:DataType="vm:ListEditorViewModel"
Title="{Binding WindowTitle}"
Width="450" Height="280"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="16" Spacing="10">
<TextBlock Text="Name" FontWeight="SemiBold"/>
<TextBox Text="{Binding Name}" PlaceholderText="List name..."/>
<TextBlock Text="Working Directory" FontWeight="SemiBold"/>
<TextBox Text="{Binding WorkingDir}" PlaceholderText="(optional) Absolute path to git repo"/>
<!-- TODO: folder picker button using IStorageProvider -->
<TextBlock Text="Default Commit Type" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding DefaultCommitType}"
MinWidth="150"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class ListEditorView : Window
{
public ListEditorView()
{
InitializeComponent();
}
}

View File

@@ -1,20 +1,55 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:v="using:ClaudeDo.Ui.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="700"
x:Class="ClaudeDo.Ui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="ClaudeDo.App">
Title="ClaudeDo" Width="1200" Height="700"
MinWidth="800" MinHeight="500">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<DockPanel>
<!-- Status Bar at bottom -->
<v:StatusBarView DockPanel.Dock="Bottom" DataContext="{Binding StatusBar}" />
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Main 3-column layout -->
<Grid ColumnDefinitions="220,Auto,*,Auto,350">
<!-- Lists pane -->
<DockPanel Grid.Column="0">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="4">
<TextBlock Text="Lists" FontWeight="Bold" FontSize="14" VerticalAlignment="Center" Margin="4,0"/>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="4">
<Button Content="+" Command="{Binding AddListCommand}" ToolTip.Tip="Add List" MinWidth="30"/>
<Button Content="E" Command="{Binding EditListCommand}" ToolTip.Tip="Edit List" MinWidth="30"/>
<Button Content="-" Command="{Binding DeleteListCommand}" ToolTip.Tip="Delete List" MinWidth="30"/>
</StackPanel>
<ListBox ItemsSource="{Binding Lists}"
SelectedItem="{Binding SelectedList}"
Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ListItemViewModel">
<StackPanel Margin="4,2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding WorkingDir}" FontSize="10" Foreground="Gray"
IsVisible="{Binding WorkingDir, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter Grid.Column="1" Width="4" ResizeDirection="Columns"/>
<!-- Tasks pane -->
<v:TaskListView Grid.Column="2" DataContext="{Binding TaskList}" />
<GridSplitter Grid.Column="3" Width="4" ResizeDirection="Columns"/>
<!-- Detail pane -->
<v:TaskDetailView Grid.Column="4" DataContext="{Binding TaskDetail}" />
</Grid>
</DockPanel>
</Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -8,4 +9,11 @@ public partial class MainWindow : Window
{
InitializeComponent();
}
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
if (DataContext is MainWindowViewModel vm)
await vm.InitializeAsync();
}
}

View File

@@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.StatusBarView"
x:DataType="vm:StatusBarViewModel">
<Border Background="#222" Padding="6,3">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6">
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
Fill="{Binding ConnectionStatus, Converter={x:Static conv:ConnectionColorConverter.Instance}}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="White" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ActiveTasksSummary}" Foreground="LightGray"
VerticalAlignment="Center" Margin="20,0,0,0" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding StatusMessage}" Foreground="Yellow"
VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class StatusBarView : UserControl
{
public StatusBarView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,93 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.TaskDetailView"
x:DataType="vm:TaskDetailViewModel">
<ScrollViewer>
<StackPanel Margin="8" Spacing="8"
IsVisible="{Binding Title, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<!-- Header -->
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="16"/>
<Border CornerRadius="3" Padding="6,2" HorizontalAlignment="Left"
Background="{Binding StatusText, Converter={x:Static conv:StatusColorConverter.Instance}}">
<TextBlock Text="{Binding StatusText}" Foreground="White" FontSize="11"/>
</Border>
<!-- Description -->
<TextBlock Text="Description" FontWeight="SemiBold" Margin="0,8,0,2"/>
<!-- TODO: Markdown rendering -->
<TextBox Text="{Binding Description, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="60"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="(no description)" Foreground="Gray" FontStyle="Italic"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
<!-- Result -->
<TextBlock Text="Result" FontWeight="SemiBold" Margin="0,8,0,2"/>
<!-- TODO: Markdown rendering -->
<TextBox Text="{Binding Result, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="80"
IsVisible="{Binding Result, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="(no result yet)" Foreground="Gray" FontStyle="Italic"
IsVisible="{Binding Result, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
<!-- Log path -->
<StackPanel Orientation="Horizontal" Spacing="4"
IsVisible="{Binding LogPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Log:" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LogPath}" FontSize="11" Foreground="Gray" VerticalAlignment="Center"/>
</StackPanel>
<!-- Live stream -->
<TextBlock Text="Live Output" FontWeight="SemiBold" Margin="0,8,0,2"/>
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="3" Padding="4"
MaxHeight="200">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding LiveLines}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontFamily="Consolas,Courier New,monospace"
FontSize="11" TextWrapping="NoWrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Worktree section -->
<Border IsVisible="{Binding HasWorktree}" BorderBrush="CornflowerBlue"
BorderThickness="1" CornerRadius="5" Padding="8" Margin="0,8,0,0">
<StackPanel Spacing="6">
<TextBlock Text="Worktree" FontWeight="Bold" FontSize="14"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Branch:" FontWeight="SemiBold"/>
<TextBlock Text="{Binding BranchName}" FontFamily="Consolas,Courier New,monospace"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="State:" FontWeight="SemiBold"/>
<TextBlock Text="{Binding WorktreeState}"/>
</StackPanel>
<TextBlock Text="Diff Stat:" FontWeight="SemiBold"
IsVisible="{Binding DiffStat, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBox Text="{Binding DiffStat, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" FontFamily="Consolas,Courier New,monospace" FontSize="11"
IsVisible="{Binding DiffStat, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<!-- Worktree actions -->
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="Open Worktree" Command="{Binding OpenWorktreeCommand}" Margin="0,0,4,4"/>
<Button Content="Show Diff" Command="{Binding ShowDiffCommand}" Margin="0,0,4,4"/>
<Button Content="Merge into main" Command="{Binding MergeIntoMainCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
<Button Content="Keep as branch" Command="{Binding KeepAsBranchCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
<Button Content="Discard" Command="{Binding DiscardCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class TaskDetailView : UserControl
{
public TaskDetailView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,41 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
x:Class="ClaudeDo.Ui.Views.TaskEditorView"
x:DataType="vm:TaskEditorViewModel"
Title="{Binding WindowTitle}"
Width="500" Height="420"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="16" Spacing="10">
<TextBlock Text="Title" FontWeight="SemiBold"/>
<TextBox Text="{Binding Title}" PlaceholderText="Task title..."/>
<TextBlock Text="Description" FontWeight="SemiBold"/>
<TextBox Text="{Binding Description}" PlaceholderText="(optional)" AcceptsReturn="True"
TextWrapping="Wrap" MinHeight="80"/>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Status" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StatusChoices}"
SelectedItem="{Binding StatusChoice}"
MinWidth="120"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Commit Type" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding CommitType}"
MinWidth="120"/>
</StackPanel>
</Grid>
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold"/>
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class TaskEditorView : Window
{
public TaskEditorView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,40 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.TaskListView"
x:DataType="vm:TaskListViewModel">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="4">
<TextBlock Text="Tasks" FontWeight="Bold" FontSize="14" VerticalAlignment="Center" Margin="4,0"/>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="4">
<Button Content="+ Task" Command="{Binding AddTaskCommand}" MinWidth="60"/>
<Button Content="Edit" Command="{Binding EditTaskCommand}" MinWidth="50"/>
<Button Content="Delete" Command="{Binding DeleteTaskCommand}" MinWidth="50"/>
</StackPanel>
<ListBox ItemsSource="{Binding Tasks}"
SelectedItem="{Binding SelectedTask}"
Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TaskItemViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="4,2">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding TagsText}" FontSize="10" Foreground="Gray"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" Margin="4,0"
Background="{Binding StatusText, Converter={x:Static conv:StatusColorConverter.Instance}}"
VerticalAlignment="Center">
<TextBlock Text="{Binding StatusText}" Foreground="White" FontSize="11"/>
</Border>
<Button Grid.Column="2" Content="Run" Command="{Binding RunNowCommand}"
Margin="4,0,0,0" VerticalAlignment="Center" Padding="8,2"
FontSize="11"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class TaskListView : UserControl
{
public TaskListView()
{
InitializeComponent();
}
}

View File

@@ -155,9 +155,12 @@ public sealed class QueueServiceTests : IDisposable
await service.StartAsync(cts.Token);
service.WakeQueue();
await Task.Delay(200);
// Wait until task1 has been picked up (poll instead of fixed delay to avoid flake under load).
var deadline = DateTime.UtcNow.AddSeconds(5);
while (order.Count == 0 && DateTime.UtcNow < deadline)
await Task.Delay(20);
// Only task1 should be running (task2 waiting).
// Only task1 should be running (task2 waiting on the queue slot).
Assert.Single(order);
Assert.Equal(task1.Id, order[0]);