fix(ui): Reactivität — Description-Save-Race, UsageMonitor-Leak, Listen-Live-Refresh
Drei unabhängige Reactivity-Bugs aus dem Polish-Audit 2026-08-20: 1. Description-Autosave überschrieb den falschen Task, weil SaveDescriptionAsync Task.Id/EditableDescription erst nach dem 400ms-Debounce las statt Row+Wert an der Aufrufstelle zu capturen (wie SaveTitleAsync es schon tat). Bind() cancelt jetzt zusätzlich einen laufenden Title-/Description-Save der vorherigen Row. 2. UsageMonitorModalViewModel abonnierte UsageUpdatedEvent erst nach dem Erst-Load-Await — schloss man das Modal währenddessen, lief das Unsubscribe in Close() ins Leere und die VM hing für immer am WorkerClient. Ein _isClosed-Flag wird jetzt nach dem Await geprüft, bevor abonniert wird. 3. Per MCP erstellte Listen blieben unsichtbar: RefreshRowAsync hatte keinen Add-Zweig für unbekannte Ids und verglich zudem die falsche Id-Form (der Worker broadcastet die rohe DB-Id, nie die "user:"-prefixte Row-Id). Ein Reconnect lud zudem nur Counts statt der vollen Listen neu.
This commit is contained in:
@@ -570,20 +570,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (_suppressDescSave || Task is null) return;
|
||||
_descSaveCts?.Cancel();
|
||||
_descSaveCts = new CancellationTokenSource();
|
||||
_ = SaveDescriptionAsync(_descSaveCts.Token);
|
||||
// Capture the row so a task switch mid-debounce cannot write A's text into B's description.
|
||||
_ = SaveDescriptionAsync(Task, value, _descSaveCts.Token);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct)
|
||||
private async System.Threading.Tasks.Task SaveDescriptionAsync(
|
||||
TaskRowViewModel row, string value, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(400, ct);
|
||||
if (Task is null) return;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var entity = await repo.GetByIdAsync(Task.Id);
|
||||
var entity = await repo.GetByIdAsync(row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription;
|
||||
entity.Description = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
@@ -617,6 +618,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
_loadCts = new CancellationTokenSource();
|
||||
var ct = _loadCts.Token;
|
||||
|
||||
// A pending debounced title/description save targets the row being unbound —
|
||||
// let it be, don't have it land on whichever row replaces it.
|
||||
_titleSaveCts?.Cancel();
|
||||
_descSaveCts?.Cancel();
|
||||
|
||||
Task = row;
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Monitor.Reset();
|
||||
|
||||
@@ -212,7 +212,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||
_worker.ConnectionRestoredEvent += () => _ = ReloadAfterReconnectAsync();
|
||||
}
|
||||
|
||||
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
||||
@@ -295,6 +295,19 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
SelectedList = Items.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reconnect can have missed offline list creates/renames/deletes (SignalR was down) — unlike
|
||||
/// the task/worktree events, which only need a count refresh — so this re-reads the full list
|
||||
/// set instead, restoring the prior selection if it still exists.
|
||||
/// </summary>
|
||||
private async Task ReloadAfterReconnectAsync()
|
||||
{
|
||||
var selectedId = SelectedList?.Id;
|
||||
await LoadAsync();
|
||||
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } restored)
|
||||
SelectedList = restored;
|
||||
}
|
||||
|
||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
@@ -438,15 +451,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
// The worker broadcasts the raw list id (ListMcpTools/ConfigMcpTools), never the
|
||||
// "user:" prefixed row id used in this VM's own collections — match on the raw id
|
||||
// either way so an MCP-driven create/rename is recognized against the loaded row.
|
||||
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
|
||||
var row = UserLists.FirstOrDefault(r => r.Id == rowId);
|
||||
if (row is null) return;
|
||||
var row = UserLists.FirstOrDefault(r =>
|
||||
(r.Id.StartsWith("user:") ? r.Id["user:".Length..] : r.Id) == rawId);
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
var entity = await lists.GetByIdAsync(rawId);
|
||||
if (entity is null) return;
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
await AddRowAsync(lists, entity);
|
||||
return;
|
||||
}
|
||||
|
||||
row.Name = entity.Name;
|
||||
row.WorkingDir = entity.WorkingDir;
|
||||
row.DefaultCommitType = entity.DefaultCommitType;
|
||||
@@ -455,4 +477,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A `create_list` from a running MCP session has no local row yet — build one from the DB
|
||||
/// entity and insert it at its DB sort position, instead of waiting for the next full reload.
|
||||
/// </summary>
|
||||
private async Task AddRowAsync(ListRepository lists, ListEntity entity)
|
||||
{
|
||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||
var item = new ListNavItemViewModel
|
||||
{
|
||||
Id = $"user:{entity.Id}",
|
||||
Name = entity.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
|
||||
WorkingDir = entity.WorkingDir,
|
||||
DefaultCommitType = entity.DefaultCommitType,
|
||||
IsManual = entity.IsManual,
|
||||
FindingsTracked = entity.FindingsTracked,
|
||||
};
|
||||
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
var ordered = (await lists.GetAllAsync()).Where(l => !seedNames.Contains(l.Name)).ToList();
|
||||
var dbIndex = ordered.FindIndex(l => l.Id == entity.Id);
|
||||
var insertAt = dbIndex < 0 ? UserLists.Count : Math.Min(dbIndex, UserLists.Count);
|
||||
|
||||
UserLists.Insert(insertAt, item);
|
||||
Items.Insert(SmartLists.Count + insertAt, item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private bool _applyingRange;
|
||||
private bool _isClosed;
|
||||
|
||||
public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
@@ -78,6 +79,7 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private void Close()
|
||||
{
|
||||
_isClosed = true;
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
@@ -95,7 +97,11 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
Snapshot = await _worker.GetUsageSnapshotAsync();
|
||||
var snapshot = await _worker.GetUsageSnapshotAsync();
|
||||
// The modal can be closed while the first-load scan is still running (see remarks
|
||||
// above) — subscribing after that would leak this VM onto the long-lived WorkerClient.
|
||||
if (_isClosed) return;
|
||||
Snapshot = snapshot;
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||
ApplyPresetRange(SelectedPresetDays);
|
||||
|
||||
Reference in New Issue
Block a user