Compare commits
5 Commits
v1.1.0
...
3423919655
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3423919655 | ||
|
|
fca2bdb596 | ||
|
|
721f0cd903 | ||
|
|
32bb52875f | ||
|
|
4f25c3dd40 |
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal 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)
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -30,35 +30,48 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void MigrateAndConfigure(ClaudeDoDbContext db)
|
public static void MigrateAndConfigure(ClaudeDoDbContext db)
|
||||||
{
|
{
|
||||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
|
||||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
|
||||||
var conn = db.Database.GetDbConnection();
|
var conn = db.Database.GetDbConnection();
|
||||||
conn.Open();
|
try
|
||||||
using (var cmd = conn.CreateCommand())
|
|
||||||
{
|
{
|
||||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
|
conn.Open();
|
||||||
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
|
||||||
|
|
||||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
|
// Set WAL FIRST, before migrations — prevents write-lock contention
|
||||||
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
// when UI and Worker start simultaneously.
|
||||||
|
using (var walCmd = conn.CreateCommand())
|
||||||
if (hasLists && !hasHistory)
|
|
||||||
{
|
{
|
||||||
// Create the history table and mark InitialCreate as applied.
|
walCmd.CommandText = "PRAGMA journal_mode=wal;";
|
||||||
cmd.CommandText = """
|
walCmd.ExecuteNonQuery();
|
||||||
CREATE TABLE "__EFMigrationsHistory" (
|
}
|
||||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
|
||||||
"ProductVersion" TEXT NOT NULL
|
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||||
);
|
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
using (var cmd = conn.CreateCommand())
|
||||||
VALUES ('20260416064948_InitialCreate', '8.0.11');
|
{
|
||||||
""";
|
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
|
||||||
cmd.ExecuteNonQuery();
|
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
||||||
|
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
|
||||||
|
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
|
||||||
|
|
||||||
|
if (hasLists && !hasHistory)
|
||||||
|
{
|
||||||
|
cmd.CommandText = """
|
||||||
|
CREATE TABLE "__EFMigrationsHistory" (
|
||||||
|
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||||
|
"ProductVersion" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||||
|
VALUES ('20260416064948_InitialCreate', '8.0.11');
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.Close();
|
finally
|
||||||
|
{
|
||||||
|
conn.Close();
|
||||||
|
}
|
||||||
|
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
SelectedList = null;
|
if (SelectedList == _pendingDeleteList)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -31,72 +31,128 @@
|
|||||||
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}">
|
||||||
DoubleTapped="OnTaskItemDoubleTapped"
|
<!-- Row 0: Task row -->
|
||||||
PointerPressed="OnTaskItemPointerPressed">
|
<Grid Grid.Row="0" ColumnDefinitions="20,Auto,*" Margin="4,4"
|
||||||
<Grid.ContextFlyout>
|
DoubleTapped="OnTaskItemDoubleTapped"
|
||||||
<MenuFlyout>
|
PointerPressed="OnTaskItemPointerPressed">
|
||||||
<MenuItem Header="Edit"
|
<Grid.ContextFlyout>
|
||||||
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
|
<MenuFlyout>
|
||||||
<MenuItem Header="Delete"
|
<MenuItem Header="Edit"
|
||||||
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).DeleteTaskCommand}"/>
|
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
|
||||||
<Separator/>
|
<MenuItem Header="Delete"
|
||||||
<MenuItem Header="Run Now"
|
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).DeleteTaskCommand}"/>
|
||||||
Command="{Binding RunNowCommand}"/>
|
<Separator/>
|
||||||
</MenuFlyout>
|
<MenuItem Header="Run Now"
|
||||||
</Grid.ContextFlyout>
|
Command="{Binding RunNowCommand}"/>
|
||||||
|
</MenuFlyout>
|
||||||
|
</Grid.ContextFlyout>
|
||||||
|
|
||||||
<!-- Circular checkbox -->
|
<!-- Expand/collapse chevron -->
|
||||||
<Border Grid.Column="0" Width="22" Height="22"
|
<Button Grid.Column="0"
|
||||||
CornerRadius="11"
|
Command="{Binding ToggleExpandedCommand}"
|
||||||
BorderThickness="2"
|
IsVisible="{Binding HasSubtasks}"
|
||||||
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
|
Background="Transparent"
|
||||||
Background="Transparent"
|
BorderThickness="0"
|
||||||
VerticalAlignment="Center" Margin="0,0,10,0"
|
Padding="0"
|
||||||
Cursor="Hand"
|
Width="16" Height="16"
|
||||||
PointerPressed="OnCheckboxPressed">
|
VerticalAlignment="Center"
|
||||||
<Panel>
|
Cursor="Hand">
|
||||||
<!-- Checkmark for done -->
|
<Panel>
|
||||||
<Canvas Width="12" Height="12"
|
<Canvas Width="10" Height="10"
|
||||||
IsVisible="{Binding IsDone}"
|
IsVisible="{Binding !IsExpanded}">
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
|
||||||
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
|
Data="M 2,0 L 8,5 L 2,10"/>
|
||||||
Data="M 1,6 L 4.5,9.5 L 11,3"/>
|
</Canvas>
|
||||||
</Canvas>
|
<Canvas Width="10" Height="10"
|
||||||
<!-- Running dot -->
|
IsVisible="{Binding IsExpanded}">
|
||||||
<Ellipse Width="8" Height="8"
|
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
|
||||||
Fill="{StaticResource StatusOrangeBrush}"
|
Data="M 0,2 L 5,8 L 10,2"/>
|
||||||
IsVisible="{Binding IsRunning}"
|
</Canvas>
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
</Panel>
|
||||||
<!-- Starting dot -->
|
</Button>
|
||||||
<Ellipse Width="8" Height="8" Fill="#FFD700"
|
|
||||||
IsVisible="{Binding IsStarting}"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
|
||||||
</Panel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Task content -->
|
<!-- Circular checkbox -->
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
<Border Grid.Column="1" Width="22" Height="22"
|
||||||
<TextBlock Text="{Binding Title}" FontWeight="Medium"
|
CornerRadius="11"
|
||||||
Foreground="{Binding TitleForeground}"
|
BorderThickness="2"
|
||||||
TextDecorations="{Binding TitleDecorations}"
|
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
Background="Transparent"
|
||||||
<TextBlock FontSize="11"
|
VerticalAlignment="Center" Margin="0,0,10,0"
|
||||||
Foreground="{StaticResource TextDimBrush}"
|
Cursor="Hand"
|
||||||
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
PointerPressed="OnCheckboxPressed">
|
||||||
<TextBlock.Text>
|
<Panel>
|
||||||
<MultiBinding StringFormat="{}{0} · {1}">
|
<Canvas Width="12" Height="12"
|
||||||
<Binding Path="TagsText"/>
|
IsVisible="{Binding IsDone}"
|
||||||
<Binding Path="StatusText"/>
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
</MultiBinding>
|
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
|
||||||
</TextBlock.Text>
|
Data="M 1,6 L 4.5,9.5 L 11,3"/>
|
||||||
</TextBlock>
|
</Canvas>
|
||||||
<TextBlock Text="{Binding StatusText}" FontSize="11"
|
<Ellipse Width="8" Height="8"
|
||||||
Foreground="{StaticResource TextDimBrush}"
|
Fill="{StaticResource StatusOrangeBrush}"
|
||||||
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
|
IsVisible="{Binding IsRunning}"
|
||||||
</StackPanel>
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<Ellipse Width="8" Height="8" Fill="#FFD700"
|
||||||
|
IsVisible="{Binding IsStarting}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Task content -->
|
||||||
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="Medium"
|
||||||
|
Foreground="{Binding TitleForeground}"
|
||||||
|
TextDecorations="{Binding TitleDecorations}"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock FontSize="11"
|
||||||
|
Foreground="{StaticResource TextDimBrush}"
|
||||||
|
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="{}{0} · {1}">
|
||||||
|
<Binding Path="TagsText"/>
|
||||||
|
<Binding Path="StatusText"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{Binding StatusText}" FontSize="11"
|
||||||
|
Foreground="{StaticResource TextDimBrush}"
|
||||||
|
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</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>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 task = await taskRepo.GetByIdAsync(taskId);
|
var taskRepo = new TaskRepository(context);
|
||||||
if (task is null)
|
var exists = await taskRepo.GetByIdAsync(taskId);
|
||||||
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
if (exists is null)
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user