5 Commits

Author SHA1 Message Date
mika kuns
3423919655 fix: resolve critical bugs and improve reliability across worker, data, UI
- Fix worker using wrong DB by defaulting to CurrentUser service account
  and expanding ~ to absolute paths at install time
- Fix DbContext disposed before fire-and-forget by passing taskId instead
  of TaskEntity into RunInSlotAsync, which creates its own context
- Fix ActiveTaskDto property casing mismatch between hub and client
- Move WAL mode PRAGMA before migrations to prevent concurrent lock issues
- Replace FirstAsync with FirstOrDefaultAsync + null guards in tag operations
- Add delete confirmation flow for lists
- Log fire-and-forget exceptions instead of swallowing them
- Broadcast RunCreated event from WorkerHub.RunNow
- Add IDisposable to MainWindowViewModel for event handler cleanup
- Preserve subtask CreatedAt on updates instead of overwriting
- Replace bare catch blocks with Debug.WriteLine logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:12:59 +02:00
mika kuns
fca2bdb596 Merge remote-tracking branch 'origin/main' 2026-04-16 12:26:50 +02:00
mika kuns
721f0cd903 Merge branch 'feat/subtask-tree-view' 2026-04-16 12:17:46 +02:00
mika kuns
32bb52875f feat(ui): add subtask tree view with expand/collapse in task list
Tasks with subtasks show a chevron for inline expand/collapse.
Subtask checkboxes toggle completion state directly. Also sets
Windows AppUserModelID for proper taskbar identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:16:22 +02:00
mika kuns
4f25c3dd40 docs: add subtask tree view design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:18:04 +02:00
17 changed files with 506 additions and 128 deletions

View File

@@ -0,0 +1,118 @@
# Subtask Tree View in Task List
## Problem
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
## Solution
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
## Scope
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
## Design
### ViewModel Changes
**TaskItemViewModel** — add:
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
- `bool HasSubtasks` — observable, set during initial load from a count query
- `int SubtaskCount` — observable, used for the indicator
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
### Repository Changes
**SubtaskRepository** — add one method:
```csharp
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
```
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
### XAML Changes
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
```
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
```
**Row 0 — Expand chevron:**
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
- Right-pointing when collapsed, down-pointing when expanded
- Bound to `ToggleExpandedCommand`
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
**Row 1 — Subtask list:**
- `ItemsControl` bound to `Subtasks`
- `IsVisible` bound to `IsExpanded`
- Left margin ~40px for visual indentation
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
### Subtask Checkbox Interaction
When a subtask checkbox is toggled in the list:
1. Update the `SubtaskItemViewModel.Completed` property
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
3. No need to refresh the parent task — subtask completion doesn't affect task status
### Subtask Context Menu
Right-click on a subtask row shows:
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
### Real-time Updates
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
### Detail Pane Sync
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
### Visual Style
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
- Subtask row vertical padding: 2px (compact)
- Indent: 40px left margin on the subtask `ItemsControl`
## Files to Modify
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
## Out of Scope
- Drag-to-reorder subtasks in the list view
- Add subtask directly from the list view
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
- Recursive task nesting (tasks containing tasks)

View File

@@ -8,14 +8,21 @@ using ClaudeDo.Ui.ViewModels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Runtime.InteropServices;
namespace ClaudeDo.App; namespace ClaudeDo.App;
sealed class Program sealed class Program
{ {
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var services = BuildServices(); var services = BuildServices();
App.Services = services; App.Services = services;

View File

@@ -30,10 +30,21 @@ public class ClaudeDoDbContext : DbContext
/// </summary> /// </summary>
public static void MigrateAndConfigure(ClaudeDoDbContext db) public static void MigrateAndConfigure(ClaudeDoDbContext db)
{ {
var conn = db.Database.GetDbConnection();
try
{
conn.Open();
// Set WAL FIRST, before migrations — prevents write-lock contention
// when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
{
walCmd.CommandText = "PRAGMA journal_mode=wal;";
walCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not, // If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration. // this is a pre-EF database. Baseline the InitialCreate migration.
var conn = db.Database.GetDbConnection();
conn.Open();
using (var cmd = conn.CreateCommand()) using (var cmd = conn.CreateCommand())
{ {
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'"; cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
@@ -44,7 +55,6 @@ public class ClaudeDoDbContext : DbContext
if (hasLists && !hasHistory) if (hasLists && !hasHistory)
{ {
// Create the history table and mark InitialCreate as applied.
cmd.CommandText = """ cmd.CommandText = """
CREATE TABLE "__EFMigrationsHistory" ( CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
@@ -56,9 +66,12 @@ public class ClaudeDoDbContext : DbContext
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
}
finally
{
conn.Close(); conn.Close();
}
db.Database.Migrate(); db.Database.Migrate();
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL");
} }
} }

View File

@@ -46,7 +46,8 @@ public sealed class ListRepository
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct); var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !list.Tags.Any(t => t.Id == tagId)) if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
{ {
@@ -57,7 +58,8 @@ public sealed class ListRepository
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId); var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null) if (tag is not null)
{ {

View File

@@ -104,7 +104,8 @@ public sealed class TaskRepository
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct); var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId)) if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{ {
@@ -115,7 +116,8 @@ public sealed class TaskRepository
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId); var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null) if (tag is not null)
{ {

View File

@@ -22,7 +22,7 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821; public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000; public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude"; public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem"; public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true; public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000; public int RestartDelayMs { get; set; } = 5000;

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
{ {
try try
{ {
// Expand ~ to the installing user's absolute path so the worker
// service always finds the correct DB regardless of service account.
var workerCfg = new InstallerWorkerConfig var workerCfg = new InstallerWorkerConfig
{ {
DbPath = ctx.DbPath, DbPath = Paths.Expand(ctx.DbPath),
SandboxRoot = ctx.SandboxRoot, SandboxRoot = Paths.Expand(ctx.SandboxRoot),
LogRoot = ctx.LogRoot, LogRoot = Paths.Expand(ctx.LogRoot),
WorktreeRootStrategy = ctx.WorktreeRootStrategy, WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot, CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs, QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort, SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin, ClaudeBin = ctx.ClaudeBin,

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using Avalonia.Threading; using Avalonia.Threading;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
@@ -208,9 +209,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt)); ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
}); });
} }
catch catch (HubException)
{ {
// Worker might not support GetActive yet // Expected: worker doesn't support GetActive yet
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"SeedActiveTasksAsync failed: {ex}");
} }
} }

View File

@@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
@@ -27,6 +27,8 @@ public partial class MainWindowViewModel : ViewModelBase
public TaskDetailViewModel TaskDetail { get; } public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; } public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
public MainWindowViewModel( public MainWindowViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker, WorkerClient worker,
@@ -42,8 +44,15 @@ public partial class MainWindowViewModel : ViewModelBase
TaskDetail = taskDetail; TaskDetail = taskDetail;
StatusBar = statusBar; StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskList.SelectedTaskChanged += OnSelectedTaskChanged; TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
TaskDetail.TaskChanged += taskId => _ = TaskList.RefreshSingleAsync(taskId); TaskDetail.TaskChanged += _onTaskChanged;
}
public void Dispose()
{
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
TaskDetail.TaskChanged -= _onTaskChanged;
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@@ -61,7 +70,11 @@ public partial class MainWindowViewModel : ViewModelBase
StatusBar.ShowMessage($"Error loading lists: {ex.Message}"); StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
} }
_ = _worker.StartAsync(); _ = _worker.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted)
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
}, TaskScheduler.Default);
} }
partial void OnSelectedListChanged(ListItemViewModel? value) partial void OnSelectedListChanged(ListItemViewModel? value)
@@ -154,23 +167,46 @@ public partial class MainWindowViewModel : ViewModelBase
} }
} }
[ObservableProperty] private bool _isDeleteConfirmVisible;
private ListItemViewModel? _pendingDeleteList;
[RelayCommand] [RelayCommand]
private async Task DeleteList() private void DeleteList()
{ {
if (SelectedList is null) return; if (SelectedList is null) return;
// TODO: confirmation dialog _pendingDeleteList = SelectedList;
IsDeleteConfirmVisible = true;
}
[RelayCommand]
private async Task ConfirmDeleteList()
{
IsDeleteConfirmVisible = false;
if (_pendingDeleteList is null) return;
try try
{ {
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context); var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(SelectedList.Id); await listRepo.DeleteAsync(_pendingDeleteList.Id);
Lists.Remove(SelectedList); Lists.Remove(_pendingDeleteList);
if (SelectedList == _pendingDeleteList)
SelectedList = null; SelectedList = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusBar.ShowMessage($"Error deleting list: {ex.Message}"); 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) private static async Task ShowDialogAsync(Window dialog)

View File

@@ -282,16 +282,18 @@ public partial class TaskDetailViewModel : ViewModelBase
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
try try
{ {
if (_taskId is null) return;
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
var subtaskRepo = new SubtaskRepository(context); var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.UpdateAsync(new SubtaskEntity await subtaskRepo.UpdateAsync(new SubtaskEntity
{ {
Id = vm.Id, Id = vm.Id,
TaskId = _taskId ?? "", TaskId = _taskId,
Title = vm.Title, Title = vm.Title,
Completed = vm.Completed, Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm), OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow, CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -378,13 +380,15 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true, UseShellExecute = true,
}); });
} }
catch { /* best effort */ } catch (Exception ex)
{
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
}
} }
[RelayCommand] [RelayCommand]
private void ShowDiff() private void ShowDiff()
{ {
// TODO: open a proper diff viewer; for now open git diff in a console
if (WorktreePath is null) return; if (WorktreePath is null) return;
try try
{ {
@@ -395,7 +399,10 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true, UseShellExecute = true,
}); });
} }
catch { /* best effort */ } catch (Exception ex)
{
Debug.WriteLine($"Failed to show diff: {ex.Message}");
}
} }
[RelayCommand] [RelayCommand]

View File

@@ -215,7 +215,10 @@ public partial class TaskEditorViewModel : ViewModelBase
{ {
if (vm.Id == "") continue; if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted) if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); {
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 else
{ {
// update order_num if position changed // update order_num if position changed

View File

@@ -1,7 +1,11 @@
using System.Collections.ObjectModel;
using Avalonia.Media; using Avalonia.Media;
using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
@@ -15,6 +19,11 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status; [ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting; [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 Id { get; }
public string ListId { get; } public string ListId { get; }
@@ -23,9 +32,13 @@ public partial class TaskItemViewModel : ViewModelBase
private readonly Func<string, Task>? _runNow; private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow; private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone; private readonly Func<string, Task>? _toggleDone;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags, public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow, Func<string, Task>? toggleDone = null) Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? toggleDone = null)
{ {
Entity = entity; Entity = entity;
Id = entity.Id; Id = entity.Id;
@@ -39,6 +52,9 @@ public partial class TaskItemViewModel : ViewModelBase
_runNow = runNow; _runNow = runNow;
_canRunNow = canRunNow; _canRunNow = canRunNow;
_toggleDone = toggleDone; _toggleDone = toggleDone;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
} }
public bool IsDone => Status == TaskStatus.Done; public bool IsDone => Status == TaskStatus.Done;
@@ -104,4 +120,55 @@ public partial class TaskItemViewModel : ViewModelBase
if (_toggleDone is not null) if (_toggleDone is not null)
await _toggleDone(Id); 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();
}
}
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

@@ -91,10 +91,17 @@ public partial class TaskListViewModel : ViewModelBase
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
var entities = await taskRepo.GetByListIdAsync(listId); 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) foreach (var e in entities)
{ {
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id); var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); subtaskCounts.TryGetValue(e.Id, out var count);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, count, ToggleDoneAsync));
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -135,7 +142,8 @@ public partial class TaskListViewModel : ViewModelBase
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
await taskRepo.AddAsync(entity); await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id); var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync); var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync);
Tasks.Add(vm); Tasks.Add(vm);
SelectedTask = vm; SelectedTask = vm;
InlineAddTitle = ""; InlineAddTitle = "";
@@ -183,7 +191,8 @@ public partial class TaskListViewModel : ViewModelBase
} }
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id); var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync));
// Auto wake-queue if agent+queued // Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued && if (saved.Status == TaskStatus.Queued &&
@@ -282,7 +291,11 @@ public partial class TaskListViewModel : ViewModelBase
} }
var tags = await taskRepo.GetEffectiveTagsAsync(taskId); var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
if (existing is not null) if (existing is not null)
{
existing.Refresh(entity, tags); existing.Refresh(entity, tags);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
} }
private async Task RunNowAsync(string taskId) private async Task RunNowAsync(string taskId)

View File

@@ -31,9 +31,11 @@
KeyDown="OnTaskListKeyDown"> KeyDown="OnTaskListKeyDown">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TaskItemViewModel"> <DataTemplate x:DataType="vm:TaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="4,4" <Grid RowDefinitions="Auto,Auto"
Background="Transparent" Background="Transparent"
Opacity="{Binding RowOpacity}" Opacity="{Binding RowOpacity}">
<!-- Row 0: Task row -->
<Grid Grid.Row="0" ColumnDefinitions="20,Auto,*" Margin="4,4"
DoubleTapped="OnTaskItemDoubleTapped" DoubleTapped="OnTaskItemDoubleTapped"
PointerPressed="OnTaskItemPointerPressed"> PointerPressed="OnTaskItemPointerPressed">
<Grid.ContextFlyout> <Grid.ContextFlyout>
@@ -48,8 +50,32 @@
</MenuFlyout> </MenuFlyout>
</Grid.ContextFlyout> </Grid.ContextFlyout>
<!-- Expand/collapse chevron -->
<Button Grid.Column="0"
Command="{Binding ToggleExpandedCommand}"
IsVisible="{Binding HasSubtasks}"
Background="Transparent"
BorderThickness="0"
Padding="0"
Width="16" Height="16"
VerticalAlignment="Center"
Cursor="Hand">
<Panel>
<Canvas Width="10" Height="10"
IsVisible="{Binding !IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 2,0 L 8,5 L 2,10"/>
</Canvas>
<Canvas Width="10" Height="10"
IsVisible="{Binding IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 0,2 L 5,8 L 10,2"/>
</Canvas>
</Panel>
</Button>
<!-- Circular checkbox --> <!-- Circular checkbox -->
<Border Grid.Column="0" Width="22" Height="22" <Border Grid.Column="1" Width="22" Height="22"
CornerRadius="11" CornerRadius="11"
BorderThickness="2" BorderThickness="2"
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}" BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
@@ -58,19 +84,16 @@
Cursor="Hand" Cursor="Hand"
PointerPressed="OnCheckboxPressed"> PointerPressed="OnCheckboxPressed">
<Panel> <Panel>
<!-- Checkmark for done -->
<Canvas Width="12" Height="12" <Canvas Width="12" Height="12"
IsVisible="{Binding IsDone}" IsVisible="{Binding IsDone}"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2" <Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
Data="M 1,6 L 4.5,9.5 L 11,3"/> Data="M 1,6 L 4.5,9.5 L 11,3"/>
</Canvas> </Canvas>
<!-- Running dot -->
<Ellipse Width="8" Height="8" <Ellipse Width="8" Height="8"
Fill="{StaticResource StatusOrangeBrush}" Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding IsRunning}" IsVisible="{Binding IsRunning}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Starting dot -->
<Ellipse Width="8" Height="8" Fill="#FFD700" <Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}" IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
@@ -78,7 +101,7 @@
</Border> </Border>
<!-- Task content --> <!-- Task content -->
<StackPanel Grid.Column="1" VerticalAlignment="Center"> <StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="Medium" <TextBlock Text="{Binding Title}" FontWeight="Medium"
Foreground="{Binding TitleForeground}" Foreground="{Binding TitleForeground}"
TextDecorations="{Binding TitleDecorations}" TextDecorations="{Binding TitleDecorations}"
@@ -98,6 +121,39 @@
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/> IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Row 1: Subtask list (visible when expanded) -->
<ItemsControl Grid.Row="1"
ItemsSource="{Binding Subtasks}"
IsVisible="{Binding IsExpanded}"
Margin="40,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="0,2"
PointerPressed="OnSubtaskPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit Task"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<CheckBox Grid.Column="0"
IsChecked="{Binding Completed, Mode=OneWay}"
VerticalAlignment="Center"
Margin="0,0,6,0"
MinWidth="0"
Click="OnSubtaskCheckboxClick"/>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>

View File

@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@@ -97,6 +98,29 @@ public partial class TaskListView : UserControl
} }
} }
private void OnSubtaskPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(null).Properties.IsRightButtonPressed
&& sender is Control { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
vm.SelectedTask = parent;
}
}
private async void OnSubtaskCheckboxClick(object? sender, RoutedEventArgs e)
{
if (sender is CheckBox { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
await parent.ToggleSubtaskDoneCommand.ExecuteAsync(subtask.Id);
}
}
public void FocusInlineAdd() public void FocusInlineAdd()
{ {
this.FindControl<TextBox>("InlineAddBox")?.Focus(); this.FindControl<TextBox>("InlineAddBox")?.Focus();

View File

@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Hub; namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
private static readonly string Version = private static readonly string Version =
@@ -12,19 +14,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly AgentFileService _agentService; private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
public WorkerHub(QueueService queue, AgentFileService agentService) public WorkerHub(QueueService queue, AgentFileService agentService, HubBroadcaster broadcaster)
{ {
_queue = queue; _queue = queue;
_agentService = agentService; _agentService = agentService;
_broadcaster = broadcaster;
} }
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
public IReadOnlyList<object> GetActive() public IReadOnlyList<ActiveTaskDto> GetActive()
{ {
return _queue.GetActive() return _queue.GetActive()
.Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt }) .Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt))
.ToList(); .ToList();
} }
@@ -33,6 +37,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
try try
{ {
await _queue.RunNow(taskId); await _queue.RunNow(taskId);
await _broadcaster.RunCreated(taskId, 1, false);
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {

View File

@@ -58,11 +58,13 @@ public sealed class QueueService : BackgroundService
public async Task RunNow(string taskId) public async Task RunNow(string taskId)
{ {
using var context = _dbFactory.CreateDbContext(); using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
var task = await taskRepo.GetByIdAsync(taskId); var exists = await taskRepo.GetByIdAsync(taskId);
if (task is null) if (exists is null)
throw new KeyNotFoundException($"Task '{taskId}' not found."); throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
lock (_lock) lock (_lock)
{ {
@@ -72,8 +74,10 @@ public sealed class QueueService : BackgroundService
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ => _ = RunInSlotAsync(taskId, "override", cts.Token).ContinueWith(t =>
{ {
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; } lock (_lock) { _overrideSlot = null; }
cts.Dispose(); cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
@@ -98,8 +102,10 @@ public sealed class QueueService : BackgroundService
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ => _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
{ {
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; } lock (_lock) { _overrideSlot = null; }
cts.Dispose(); cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
@@ -165,8 +171,10 @@ public sealed class QueueService : BackgroundService
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts }; _queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ => _ = RunInSlotAsync(task.Id, "queue", cts.Token).ContinueWith(t =>
{ {
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
lock (_lock) { _queueSlot = null; } lock (_lock) { _queueSlot = null; }
cts.Dispose(); cts.Dispose();
WakeQueue(); // Check for next task immediately. WakeQueue(); // Check for next task immediately.
@@ -186,16 +194,25 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping"); _logger.LogInformation("QueueService stopping");
} }
private async Task RunInSlotAsync(TaskEntity task, string slot, CancellationToken ct) private async Task RunInSlotAsync(string taskId, string slot, CancellationToken ct)
{ {
try try
{ {
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", task.Id, slot); _logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
TaskEntity task;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
await _runner.RunAsync(task, slot, ct); await _runner.RunAsync(task, slot, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id); _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
} }
} }