Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ee505057 | ||
|
|
06bc3d2105 | ||
|
|
bd5aa265d1 | ||
|
|
b298594a2c | ||
|
|
62855ac575 | ||
|
|
18b4dcd56f | ||
|
|
b02e0f80f0 | ||
|
|
df3e7ba0c7 | ||
|
|
a1b20d9f27 | ||
|
|
e0c6b670cb | ||
|
|
2bd48ea1c7 | ||
|
|
77c70d74cb | ||
|
|
f24654dfb8 | ||
|
|
b05b6fbaec | ||
|
|
eb3aee41a4 | ||
|
|
2b8733227d |
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## v2.13.1 — 2026-09-02
|
||||
|
||||
### Fixes
|
||||
|
||||
- Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft (9f64ba50)
|
||||
|
||||
## v2.13.0 — 2026-09-02
|
||||
|
||||
### Features
|
||||
|
||||
- Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten (fe818651)
|
||||
- start_list_handler MCP-Tool startet einen List-Handler-Run wie der Button (c2166a0d)
|
||||
- Handler-Session nach Usage-Limit-Reset automatisch neu starten (016b9203)
|
||||
- PowerUse-Zeitfenster + Handoff-Schwellwert als Settings (69898be5)
|
||||
- PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste (19375254)
|
||||
- PowerUse-Addendum an die Handler-Prompts, Cap aus den Settings (453d61ca)
|
||||
- PowerUse-Flags und konfigurierbares Handoff-Budget (3cf9628f)
|
||||
- depends_on als Menge statt Einzelfeld (c25732cf)
|
||||
- surface the handoff round budget in the merge-helper handoff brief (81a5fc5d)
|
||||
- add ExpectsNoDiff flag for verification/audit tasks (0b506f52)
|
||||
- add dependency audit and diff-review threshold to list-handler prompts (9e805646)
|
||||
- add "move to list" to the task row context menu (80fe188a)
|
||||
- notes get their own list, a day log and an Enter chain (0f04b796)
|
||||
- add_note MCP tool with a per-day agent cap (ae0c7d37)
|
||||
|
||||
### Fixes
|
||||
|
||||
- carry ExpectsNoDiff into the relocated merge MCP tools (4ee1d05f)
|
||||
- stop Ui.Tests order-dependent flakiness on shared Loc.Current (dc1e67d0)
|
||||
- add failureReason field to batch_get_tasks (91220177)
|
||||
- make locale-parity test able to detect a malformed locale file (4c78a5d6)
|
||||
- don't leave an orphaned list behind on cancel (56bc1651)
|
||||
- stop treating git worktrees and submodules as repos (b6e5876b)
|
||||
- fix Bandel ticket sync and reactivate the integration (30d0611b)
|
||||
- Shift+Enter in the capture box, not just in the note rows (f6405804)
|
||||
- let Shift+Enter break the line, and clear the leftovers around the editor (b90c5855)
|
||||
- auto-bootstrap commit-less repos before creating worktrees (e0748ba3)
|
||||
- bootstrap commit-less repos and survive missing git identity (de44196b)
|
||||
|
||||
### Performance
|
||||
|
||||
- switch to workstation GC to cut idle memory (c347e4fc)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- share StubWorkerClient between Ui.Tests and Worker.Tests (9f90e75a)
|
||||
- remove the stale-worker banner (6be62f02)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Handler-Prompt praezisieren (Wave-Plan, Task-Split, review_task-Aufruf) (20b3b4ce)
|
||||
- Test-Scoping fuer Implementer-Tasks, PowerUse in den Explore-Notes (416997d7)
|
||||
- plan for multi-dependency depends_on relation table (84d955df)
|
||||
- empty-repo hardening notes + verification item (cf093e31)
|
||||
- spec + plan for empty-repo (unborn HEAD) hardening (fea8236e)
|
||||
- update for v2.12.0 (1d6cd49b)
|
||||
|
||||
## v2.12.0 — 2026-08-28
|
||||
|
||||
### Features
|
||||
|
||||
+1
-3
@@ -15,9 +15,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
|
||||
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
|
||||
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
|
||||
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
|
||||
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
|
||||
- **Modal-Bodies unten abgeschnitten — gefixt, Sichtprüfung offen** (Ursache 2026-09-02 verifiziert: `ScrollViewer.Padding` zählt in Avalonia 12 nicht zum Extent; Fix ist das Padding auf ein `Margin` des inneren Inhalts umzuziehen). Betraf `AboutModalView`, `MergeHelperSelectionModal`, `MergeModalView`, `RepoImportModalView`, `WorktreesOverviewModalView` — alle fünf umgestellt. `ListSettingsModalView`, `LogVisualizerView` und `WorkConsole` hatten zu diesem Zeitpunkt bereits kein ScrollViewer-Padding mehr.
|
||||
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
|
||||
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
|
||||
|
||||
@@ -217,6 +217,10 @@ public sealed record LaunchSpec(
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
// A handler round's still-open (Queued/Running) task, for the Mission Control "abandon run"
|
||||
// action -- confirms with a count, then cancels each one before closing the pane.
|
||||
public record HandlerOpenTaskDto(string Id, string Title);
|
||||
|
||||
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
|
||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
||||
|
||||
@@ -354,6 +354,10 @@
|
||||
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
|
||||
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
||||
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
|
||||
"abandonRun": "Lauf abbrechen",
|
||||
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
|
||||
"abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.",
|
||||
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
"placeholder": "Antwort eingeben…",
|
||||
@@ -772,7 +776,7 @@
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —" },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" },
|
||||
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
|
||||
@@ -354,6 +354,10 @@
|
||||
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
||||
"enqueueFailed": "Queue failed: {0}",
|
||||
"queueRefreshFailed": "Couldn't refresh the queue: {0}",
|
||||
"abandonRun": "Abandon run",
|
||||
"abandonRunTip": "Cancel this run's still-open tasks and close the session",
|
||||
"abandonRunConfirm": "Cancel {0} open task(s) and close this session? Tasks already in review or done are not affected.",
|
||||
"abandonRunFailed": "Couldn't cancel the open tasks: {0}",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
"placeholder": "Type your answer…",
|
||||
@@ -772,7 +776,7 @@
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —" },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" },
|
||||
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
||||
|
||||
@@ -158,6 +158,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// running. The exit code only exists in this process; the worker decides whether it was a
|
||||
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
|
||||
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
|
||||
/// <summary>The handler round's still-open (Queued/Running) tasks, for the Mission Control
|
||||
/// "abandon run" action. Empty when the round has no entry or nothing open -- the caller then
|
||||
/// closes the pane without a confirmation dialog.</summary>
|
||||
Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -68,6 +68,14 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
||||
try
|
||||
{
|
||||
// If ClaudeDo.App itself was started from inside a claude session (e.g. a dev/test
|
||||
// launch), it inherits these markers and every embedded PTY session would otherwise
|
||||
// inherit them too — the child claude then thinks it's a spawned subagent and
|
||||
// disables transcript saving / degrades its own startup banner. Every ConPTY tile
|
||||
// must look like a fresh top-level session regardless of how ClaudeDo.App was launched.
|
||||
Environment.SetEnvironmentVariable("CLAUDE_CODE_CHILD_SESSION", null);
|
||||
Environment.SetEnvironmentVariable("CLAUDECODE", null);
|
||||
|
||||
foreach (var (key, value) in descriptor.Env)
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
|
||||
|
||||
@@ -641,6 +641,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
|
||||
|
||||
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", () => _hub.InvokeAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", handlerTaskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
|
||||
// The list_config row fetched by the most recent LoadForListAsync — exposed so a caller that
|
||||
// needs the same row (ListSettingsModalViewModel) can reuse it instead of fetching it again.
|
||||
public ListConfigDto? LastLoadedListConfig { get; private set; }
|
||||
|
||||
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
_suppressSave = true;
|
||||
@@ -246,6 +250,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
await LoadGlobalDefaultsAsync();
|
||||
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
LastLoadedListConfig = cfg;
|
||||
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
|
||||
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -408,7 +409,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (Dialogs is not null && _services is not null)
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (!vm.Saved && !vm.Deleted)
|
||||
{
|
||||
|
||||
@@ -112,6 +112,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||||
private string? _blockedByTaskId;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||||
@@ -276,7 +277,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
&& !HasInteractiveSession;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
|
||||
|
||||
@@ -18,6 +18,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
|
||||
public bool IsTaskBased => TaskId is not null;
|
||||
|
||||
/// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
|
||||
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
|
||||
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
|
||||
/// pane is added to the visible collection, so no change notification is needed.</summary>
|
||||
public bool IsListHandler { get; set; }
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
||||
@@ -26,6 +32,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
||||
[ObservableProperty] private bool _isSubmitPending;
|
||||
|
||||
/// <summary>Set by the host while an abandon-run round trip (query open tasks, confirm,
|
||||
/// cancel each, close pane) is in flight, so a rapid double-click can't race two runs.</summary>
|
||||
[ObservableProperty] private bool _isAbandonPending;
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
@@ -36,6 +46,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||
public event Action<string>? SubmitForReviewRequested;
|
||||
|
||||
/// <summary>Raised when the user abandons a list-handler run (<see cref="IsListHandler"/>
|
||||
/// only): the host looks up this round's still-open tasks, confirms with the user (naming the
|
||||
/// count), cancels them, then closes this pane -- one action instead of closing the tile and
|
||||
/// separately cancelling the queue.</summary>
|
||||
public event Action<string>? AbandonRunRequested;
|
||||
|
||||
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
|
||||
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
|
||||
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
|
||||
@@ -141,6 +157,16 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
|
||||
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
|
||||
private bool CanAbandonRun() => IsListHandler && !IsAbandonPending;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanAbandonRun))]
|
||||
private void AbandonRun()
|
||||
{
|
||||
if (TaskId is { } id) AbandonRunRequested?.Invoke(id);
|
||||
}
|
||||
|
||||
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
|
||||
|
||||
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
|
||||
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||
|
||||
@@ -79,6 +79,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// MissionControlView.OnNewSessionClicked).
|
||||
public Action? BrowseForSessionRequested { get; set; }
|
||||
|
||||
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
|
||||
// view the same way TasksIslandViewModel/DetailsIslandViewModel are.
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
public UsagePillViewModel UsagePill { get; }
|
||||
|
||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
||||
@@ -375,7 +379,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => launchSpec(taskId))));
|
||||
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -416,7 +420,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
|
||||
{ IsListHandler = true });
|
||||
}
|
||||
|
||||
// The 5h usage window is about to run out under this handler session: type the worker's warning
|
||||
@@ -440,6 +445,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
ConPtySessions.Add(pane);
|
||||
pane.Start();
|
||||
}
|
||||
@@ -495,6 +501,41 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Handler-run abort ("Not-Aus"): cancels every still-open (Queued/Running) task this handler
|
||||
// round queued or started, then closes the pane -- one action instead of "close tile" + "cancel
|
||||
// the tasks separately", which used to leave orphaned rows nobody merges. No open tasks (or no
|
||||
// round entry at all -- 7-day retention, or a run predating this feature) closes the pane
|
||||
// straight away, no confirmation needed. Guarded by IsAbandonPending like OnPaneSubmitForReview.
|
||||
private async void OnPaneAbandonRun(string taskId)
|
||||
{
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsAbandonPending)
|
||||
return;
|
||||
|
||||
pane.IsAbandonPending = true;
|
||||
try
|
||||
{
|
||||
var open = await _worker.GetHandlerOpenTasksAsync(taskId);
|
||||
if (open.Count > 0)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
if (!await ConfirmAsync(Loc.T("missionControl.abandonRunConfirm", open.Count))) return;
|
||||
|
||||
foreach (var task in open)
|
||||
await _worker.CancelTaskAsync(task.Id);
|
||||
}
|
||||
|
||||
CloseConPtySession(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.abandonRunFailed", ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pane.IsAbandonPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
@@ -502,6 +543,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
@@ -555,6 +597,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
public Func<string, Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
// Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError.
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
@@ -56,6 +59,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
|
||||
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
|
||||
|
||||
// Gates Save/Delete while the core fields (name/agent/verify command) are still loading, so a
|
||||
// save can't land before they're populated and clobber the list_config row with blanks. Ticket
|
||||
// projects load separately (see LoadTicketProjectsAsync) and are NOT covered by this — Save's
|
||||
// own tri-state handles that case instead.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
|
||||
private bool _isLoading;
|
||||
|
||||
// Set when the core load (name/agent/verify command) threw — keeps Save gated even after
|
||||
// IsLoading resets to false in LoadAsync's finally, so a failed load can't clobber the
|
||||
// stored config with blank fields.
|
||||
private bool _loadFailed;
|
||||
|
||||
// True once GetTicketProjectsAsync has completed successfully for the current load — Save
|
||||
// only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket
|
||||
// system leaves the stored link untouched instead of wiping it to "none".
|
||||
private bool _ticketProjectsLoaded;
|
||||
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||
|
||||
public const string DefaultIcon = "Folder";
|
||||
@@ -91,6 +113,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
|
||||
}
|
||||
|
||||
// Fires the load without blocking the caller, so the modal window can be shown right away
|
||||
// instead of appearing only after seven serial hub round-trips (one of them a 10s-timeout
|
||||
// ticket-system HTTP call) have finished — same pattern as UsageMonitorModalViewModel.BeginLoad.
|
||||
public void BeginLoad(
|
||||
string listId,
|
||||
string name,
|
||||
string? workingDir,
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null)
|
||||
=> _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
||||
|
||||
public async Task LoadAsync(
|
||||
string listId,
|
||||
string name,
|
||||
@@ -101,43 +136,81 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string? icon = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
FindingsTracked = findingsTracked;
|
||||
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
||||
// icon on the next save — fall back to the default instead.
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
||||
|
||||
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
|
||||
? await _worker.GetTicketSettingsAsync()
|
||||
: null;
|
||||
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
|
||||
TicketProjects.Clear();
|
||||
if (TicketsAvailable)
|
||||
ListConfigDto? cfg = null;
|
||||
IsLoading = true;
|
||||
_loadFailed = false;
|
||||
try
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
FindingsTracked = findingsTracked;
|
||||
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
||||
// icon on the next save — fall back to the default instead.
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
cfg = Agent.LastLoadedListConfig;
|
||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// BeginLoad fires this without awaiting it — catching here (instead of letting it
|
||||
// propagate) turns what would be an unobserved task exception, with the modal stuck
|
||||
// open and every field blank, into a reported error and a locked Save button.
|
||||
_loadFailed = true;
|
||||
ErrorReported?.Invoke(Loc.T("vm.listSettings.loadFailed", ex.Message));
|
||||
}
|
||||
finally { IsLoading = false; }
|
||||
|
||||
if (_loadFailed) return;
|
||||
|
||||
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
|
||||
// up the rest of the modal. The dropdown just stays empty until this finishes.
|
||||
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
|
||||
}
|
||||
|
||||
private async Task LoadTicketProjectsAsync(int? existingTicketProjectId)
|
||||
{
|
||||
_ticketProjectsLoaded = false;
|
||||
TicketProjects.Clear();
|
||||
try
|
||||
{
|
||||
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
|
||||
? await _worker.GetTicketSettingsAsync()
|
||||
: null;
|
||||
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
|
||||
if (!TicketsAvailable)
|
||||
{
|
||||
SelectedTicketProject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone")));
|
||||
foreach (var p in await _worker.GetTicketProjectsAsync())
|
||||
TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}"));
|
||||
|
||||
SelectedTicketProject = cfg?.TicketProjectId is int id
|
||||
SelectedTicketProject = existingTicketProjectId is int id
|
||||
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
|
||||
: TicketProjects[0];
|
||||
_ticketProjectsLoaded = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
SelectedTicketProject = null;
|
||||
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
|
||||
// skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored
|
||||
// link survives instead of being wiped to "none".
|
||||
ErrorReported?.Invoke(Loc.T("vm.listSettings.ticketProjectsLoadFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private bool CanSave() => !IsLoading && !_loadFailed;
|
||||
private bool CanDelete() => !IsLoading;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
await _worker.UpdateListAsync(new UpdateListDto(
|
||||
@@ -149,10 +222,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
FindingsTracked,
|
||||
Icon));
|
||||
|
||||
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
|
||||
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
|
||||
// Never null here, or an explicit unlink would silently fail to reach the worker.
|
||||
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
||||
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
|
||||
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
|
||||
// and loaded -> always an explicit value, 0 for "— none —" to actively clear it. Never
|
||||
// null in that case, or an explicit unlink would silently fail to reach the worker.
|
||||
var ticketProjectId = TicketsAvailable && _ticketProjectsLoaded ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
||||
|
||||
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
|
||||
// the worker as a real false rather than a "leave it alone".
|
||||
@@ -165,7 +239,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8,3">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding DisplayTitle}"
|
||||
@@ -25,7 +25,12 @@
|
||||
Content="{loc:Tr missionControl.submitForReview}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
<Button Grid.Column="2" Classes="title-ctrl"
|
||||
<Button Grid.Column="2" Classes="btn" Margin="0,0,6,0"
|
||||
IsVisible="{Binding IsListHandler}"
|
||||
Content="{loc:Tr missionControl.abandonRun}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.abandonRunTip}"
|
||||
Command="{Binding AbandonRunCommand}" />
|
||||
<Button Grid.Column="3" Classes="title-ctrl"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
@@ -55,6 +56,7 @@ public partial class MissionControlView : UserControl
|
||||
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = null;
|
||||
_vm.ConfirmAsync = null;
|
||||
}
|
||||
|
||||
_vm = DataContext as MissionControlViewModel;
|
||||
@@ -64,12 +66,59 @@ public partial class MissionControlView : UserControl
|
||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
||||
_vm.ConfirmAsync = ShowConfirmAsync;
|
||||
}
|
||||
|
||||
RebuildOverviewGrid();
|
||||
UpdateQueueColumn();
|
||||
}
|
||||
|
||||
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
|
||||
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
|
||||
private async Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.about.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<!-- Body -->
|
||||
<ScrollViewer Padding="20,16" HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid Margin="20,16" RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
|
||||
<TextBlock Classes="meta" Grid.Row="0" Grid.Column="0" Text="{loc:Tr modals.about.version}" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="meta" Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="meta" Grid.Row="1" Grid.Column="0" Text="{loc:Tr modals.about.data}" VerticalAlignment="Center"/>
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Padding="20,2,20,8">
|
||||
<StackPanel>
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="20,2,20,8">
|
||||
<TextBlock Classes="meta" Margin="0,8"
|
||||
Text="{loc:Tr modals.mergeHelper.empty}"
|
||||
IsVisible="{Binding !HasTasks}"/>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<!-- Body -->
|
||||
<ScrollViewer Padding="20,16">
|
||||
<StackPanel Spacing="12">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="20,16" Spacing="12">
|
||||
|
||||
<TextBlock Classes="title" Text="{Binding TaskTitle, StringFormat='Merging: {0}'}" />
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
</StackPanel>
|
||||
|
||||
<!-- Repo checklist -->
|
||||
<ScrollViewer Padding="20,2,20,8">
|
||||
<ItemsControl ItemsSource="{Binding Repos}">
|
||||
<ScrollViewer>
|
||||
<ItemsControl Margin="20,2,20,8" ItemsSource="{Binding Repos}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollViewer Padding="20,16">
|
||||
<StackPanel>
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="20,16">
|
||||
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly TicketClientFactory _ticketClients;
|
||||
private readonly TicketImportService _ticketImport;
|
||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||
private readonly HandlerRoundState? _roundState;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -112,7 +113,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
TicketPatStore ticketPat = null!,
|
||||
TicketClientFactory ticketClients = null!,
|
||||
TicketImportService ticketImport = null!,
|
||||
HandlerRestartCoordinator? handlerRestart = null)
|
||||
HandlerRestartCoordinator? handlerRestart = null,
|
||||
HandlerRoundState? roundState = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -152,6 +154,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ticketClients = ticketClients;
|
||||
_ticketImport = ticketImport;
|
||||
_handlerRestart = handlerRestart;
|
||||
_roundState = roundState;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -810,6 +813,24 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
|
||||
}
|
||||
|
||||
// The Mission Control "abandon run" action: what a handler round queued/started that is still
|
||||
// open (Queued or Running), read from the structured HandlerRoundState store rather than
|
||||
// parsed out of the handler task's description. Empty when there is no round entry (7-day
|
||||
// retention, or a run from before this feature) or the round has no open tasks -- the caller
|
||||
// treats both as "nothing to confirm, just close the pane".
|
||||
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasks(string handlerTaskId)
|
||||
{
|
||||
var round = _roundState?.Get(handlerTaskId);
|
||||
if (round is not { TaskIds.Count: > 0 }) return Array.Empty<HandlerOpenTaskDto>();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
return await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => round.TaskIds.Contains(t.Id)
|
||||
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Running))
|
||||
.Select(t => new HandlerOpenTaskDto(t.Id, t.Title))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
|
||||
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
|
||||
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
||||
|
||||
@@ -430,6 +430,17 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
||||
|
||||
// A dequeued task may have been a planning-chain predecessor. Without unblocking its
|
||||
// successor here, the successor is left permanently wedged on a BlockedByTaskId link to
|
||||
// a task that has left the queue and will never reach the terminal state that would
|
||||
// otherwise resolve it (PlanningChainCoordinator.OnChildFinishedAsync).
|
||||
var successorId = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.BlockedByTaskId == taskId)
|
||||
.Select(t => t.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (successorId is not null)
|
||||
await UnblockAsync(successorId, ct);
|
||||
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
public virtual Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<HandlerOpenTaskDto>>(Array.Empty<HandlerOpenTaskDto>());
|
||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
// Perf fix: the modal used to await seven serial hub round-trips (including a 10s-timeout
|
||||
// ticket-system HTTP call) before showing itself. LoadAsync must fetch the list config exactly
|
||||
// once and must never let a slow/failing ticket-projects call block or corrupt the rest of the
|
||||
// load, or the save that follows it.
|
||||
public class ListSettingsModalViewModelTests
|
||||
{
|
||||
static ListSettingsModalViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private static IDbContextFactory<ClaudeDoDbContext> NewFactory()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_listsettings_{Guid.NewGuid():N}.db");
|
||||
ClaudeDoDbContext New() => new(new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}").Options);
|
||||
using (var ctx = New()) ctx.Database.EnsureCreated();
|
||||
return new TestDbFactory(New);
|
||||
}
|
||||
|
||||
private sealed class CountingWorker : StubWorkerClient
|
||||
{
|
||||
public int GetListConfigCalls;
|
||||
public ListConfigDto? Cfg;
|
||||
public TicketSettingsDto? TicketSettings = new("http://ticket.example", true);
|
||||
public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory;
|
||||
public UpdateListConfigDto? SavedListConfig;
|
||||
|
||||
public Exception? GetListConfigThrows;
|
||||
|
||||
public override Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||
{
|
||||
GetListConfigCalls++;
|
||||
if (GetListConfigThrows is not null) throw GetListConfigThrows;
|
||||
return Task.FromResult(Cfg);
|
||||
}
|
||||
|
||||
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(TicketSettings);
|
||||
|
||||
public override Task<List<TicketProjectDto>> GetTicketProjectsAsync() =>
|
||||
TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List<TicketProjectDto>());
|
||||
|
||||
public override Task UpdateListConfigAsync(UpdateListConfigDto dto)
|
||||
{
|
||||
SavedListConfig = dto;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
||||
{
|
||||
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
|
||||
Assert.Equal(1, worker.GetListConfigCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_TicketProjectsNeverResolving_DoesNotBlockRestOfLoad()
|
||||
{
|
||||
var pending = new TaskCompletionSource<List<TicketProjectDto>>();
|
||||
var worker = new CountingWorker
|
||||
{
|
||||
Cfg = new ListConfigDto("opus", null, null, 42, null, "make test", "acceptEdits", 7, true),
|
||||
TicketProjectsFactory = () => pending.Task,
|
||||
};
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
// LoadAsync must complete on its own even though the ticket-projects call it kicked off
|
||||
// never resolves -- ticket loading is fire-and-forget precisely so a slow/unreachable
|
||||
// ticket system can't hold up the rest of the modal.
|
||||
var loadTask = vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
var completed = await Task.WhenAny(loadTask, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
|
||||
Assert.Same(loadTask, completed);
|
||||
Assert.Equal("make test", vm.VerifyCommand);
|
||||
Assert.True(vm.PowerUseAllowed);
|
||||
Assert.False(vm.IsLoading);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_WhileTicketProjectsUnreachable_PreservesStoredTicketLink()
|
||||
{
|
||||
var worker = new CountingWorker
|
||||
{
|
||||
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
|
||||
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system unreachable"),
|
||||
};
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(worker.SavedListConfig);
|
||||
Assert.Null(worker.SavedListConfig!.TicketProjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_AfterSuccessfulTicketLoad_WritesSelectedTicketProject()
|
||||
{
|
||||
var worker = new CountingWorker
|
||||
{
|
||||
Cfg = new ListConfigDto("opus", null, null, null, null, null, null, 7, false),
|
||||
TicketProjectsFactory = () => Task.FromResult(new List<TicketProjectDto>
|
||||
{
|
||||
new(7, "Fix the thing", "Dev"),
|
||||
}),
|
||||
};
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(7, worker.SavedListConfig!.TicketProjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CoreLoadThrows_BlocksSaveAndDoesNotOverwriteConfig()
|
||||
{
|
||||
var worker = new CountingWorker
|
||||
{
|
||||
Cfg = new ListConfigDto("opus", null, null),
|
||||
GetListConfigThrows = new InvalidOperationException("worker offline"),
|
||||
};
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
|
||||
Assert.False(vm.SaveCommand.CanExecute(null));
|
||||
Assert.NotNull(reported);
|
||||
Assert.Contains("worker offline", reported);
|
||||
Assert.Null(worker.SavedListConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_TicketProjectsThrow_ReportsErrorButRestOfLoadStillWorks()
|
||||
{
|
||||
var worker = new CountingWorker
|
||||
{
|
||||
Cfg = new ListConfigDto("opus", null, null, null, null, "make test", null, 7, true),
|
||||
TicketProjectsFactory = () => throw new InvalidOperationException("ticket system down"),
|
||||
};
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
|
||||
Assert.Equal("make test", vm.VerifyCommand);
|
||||
Assert.True(vm.PowerUseAllowed);
|
||||
Assert.True(vm.SaveCommand.CanExecute(null));
|
||||
Assert.NotNull(reported);
|
||||
Assert.Contains("ticket system down", reported);
|
||||
}
|
||||
}
|
||||
@@ -966,4 +966,110 @@ public class MissionControlViewModelTests : IDisposable
|
||||
vm.ToggleLayoutCommand.Execute(null);
|
||||
Assert.False(vm.IsFocusMode);
|
||||
}
|
||||
|
||||
// ── Abandon run: single-action "stop session + cancel its still-open tasks" ────────────────
|
||||
|
||||
private sealed class RecordingAbandonWorker : StubWorkerClient
|
||||
{
|
||||
public IReadOnlyList<HandlerOpenTaskDto> OpenTasks { get; set; } = Array.Empty<HandlerOpenTaskDto>();
|
||||
public readonly List<string> Cancelled = new();
|
||||
|
||||
public override Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(OpenTasks);
|
||||
|
||||
public override Task CancelTaskAsync(string taskId)
|
||||
{
|
||||
Cancelled.Add(taskId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// A plain OpenConPtySessionAsync pane (not a list handler) never shows/allows the action --
|
||||
// acceptance criterion 5 ("only at handler panes").
|
||||
[Fact]
|
||||
public async Task AbandonRun_OnNonHandlerPane_IsNotAvailable()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
|
||||
Assert.False(pane.IsListHandler);
|
||||
Assert.False(pane.AbandonRunCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbandonRun_ListHandlerPane_IsListHandlerTrue_CommandAvailable()
|
||||
{
|
||||
var worker = new RecordingAbandonWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
Assert.True(pane.IsListHandler);
|
||||
Assert.True(pane.AbandonRunCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// Acceptance criterion 4: no open tasks (or no round entry, which the fake models the same
|
||||
// way) closes the pane straight away -- no confirmation dialog.
|
||||
[Fact]
|
||||
public async Task AbandonRun_NoOpenTasks_ClosesPaneWithoutConfirming()
|
||||
{
|
||||
var worker = new RecordingAbandonWorker { OpenTasks = Array.Empty<HandlerOpenTaskDto>() };
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
var confirmCalled = false;
|
||||
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
|
||||
|
||||
pane.AbandonRunCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.False(confirmCalled);
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
// Acceptance criterion 3 (decline branch): declining leaves the session and its tasks
|
||||
// untouched.
|
||||
[Fact]
|
||||
public async Task AbandonRun_OpenTasks_ConfirmDeclined_LeavesSessionAndTasksUntouched()
|
||||
{
|
||||
var worker = new RecordingAbandonWorker
|
||||
{
|
||||
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
|
||||
};
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
string? confirmMessage = null;
|
||||
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
|
||||
|
||||
pane.AbandonRunCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.NotNull(confirmMessage);
|
||||
Assert.Empty(worker.Cancelled);
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
// Acceptance criteria 1-3 (accept branch): the count is confirmed, every open task is
|
||||
// cancelled, then the pane closes -- one action instead of two.
|
||||
[Fact]
|
||||
public async Task AbandonRun_OpenTasks_ConfirmAccepted_CancelsEachThenClosesPane()
|
||||
{
|
||||
var worker = new RecordingAbandonWorker
|
||||
{
|
||||
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
|
||||
};
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
vm.ConfirmAsync = _ => Task.FromResult(true);
|
||||
|
||||
pane.AbandonRunCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,23 @@ public class TaskRowContextMenuTests
|
||||
Assert.NotNull(row.SendToQueueDisabledReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRemoveFromQueue_True_When_Queued_And_Blocked()
|
||||
{
|
||||
var row = MakeRow();
|
||||
row.Status = TaskStatus.Queued;
|
||||
row.BlockedByTaskId = "predecessor-id";
|
||||
Assert.True(row.CanRemoveFromQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRemoveFromQueue_False_When_Idle()
|
||||
{
|
||||
var row = MakeRow();
|
||||
row.Status = TaskStatus.Idle;
|
||||
Assert.False(row.CanRemoveFromQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelDisabledReason_Null_While_Running()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// Covers WorkerHub.GetHandlerOpenTasks -- the Mission Control "abandon run" action's read side.
|
||||
/// The round's TaskIds come from HandlerRoundState (structured store, not the handler task's
|
||||
/// description); only rows still Queued or Running are returned, so WaitingForReview/Done/Failed/
|
||||
/// Cancelled/Idle work is never touched by a subsequent cancel loop.
|
||||
public sealed class HandlerOpenTasksHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly string _roundsPath = Path.Combine(Path.GetTempPath(), $"claudedo_rounds_{Guid.NewGuid():N}.json");
|
||||
private readonly HandlerRoundState _rounds;
|
||||
|
||||
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||
{
|
||||
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
|
||||
}
|
||||
|
||||
public HandlerOpenTasksHubTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_rounds = new HandlerRoundState(_roundsPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
try { File.Delete(_roundsPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var factory = _db.CreateFactory();
|
||||
var git = new GitService();
|
||||
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), Microsoft.Extensions.Logging.Abstractions.NullLogger<WorktreeManager>.Instance);
|
||||
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
||||
new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker(), _rounds);
|
||||
var built = TaskStateServiceBuilder.Build(factory);
|
||||
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, factory, null!, null!, null!,
|
||||
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
|
||||
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
|
||||
roundState: _rounds);
|
||||
return hub;
|
||||
}
|
||||
|
||||
private async Task<string> SeedListAsync(string name = "L")
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = Path.GetTempPath(), CreatedAt = DateTime.UtcNow });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status, string title = "T")
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRoundEntry_ReturnsEmpty()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
var result = await hub.GetHandlerOpenTasks("no-such-handler-task");
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundWithOnlyTerminalTasks_ReturnsEmpty()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
|
||||
var review = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, "Review task");
|
||||
var failed = await SeedTaskAsync(listId, TaskStatus.Failed, "Failed task");
|
||||
var cancelled = await SeedTaskAsync(listId, TaskStatus.Cancelled, "Cancelled task");
|
||||
var idle = await SeedTaskAsync(listId, TaskStatus.Idle, "Idle task");
|
||||
var handlerTaskId = "handler-1";
|
||||
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
|
||||
new[] { done.Id, review.Id, failed.Id, cancelled.Id, idle.Id }, DateTime.UtcNow));
|
||||
|
||||
var hub = CreateHub();
|
||||
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundWithMixedTasks_ReturnsOnlyQueuedAndRunning()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var queued = await SeedTaskAsync(listId, TaskStatus.Queued, "Queued task");
|
||||
var running = await SeedTaskAsync(listId, TaskStatus.Running, "Running task");
|
||||
var done = await SeedTaskAsync(listId, TaskStatus.Done, "Done task");
|
||||
var notInRound = await SeedTaskAsync(listId, TaskStatus.Queued, "Unrelated queued task");
|
||||
var handlerTaskId = "handler-2";
|
||||
_rounds.Save(new HandlerRound(handlerTaskId, "wait",
|
||||
new[] { queued.Id, running.Id, done.Id }, DateTime.UtcNow));
|
||||
|
||||
var hub = CreateHub();
|
||||
var result = await hub.GetHandlerOpenTasks(handlerTaskId);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, r => r.Id == queued.Id && r.Title == "Queued task");
|
||||
Assert.Contains(result, r => r.Id == running.Id && r.Title == "Running task");
|
||||
Assert.DoesNotContain(result, r => r.Id == notInRound.Id);
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,23 @@ public sealed class TaskStateServiceTests : IDisposable
|
||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DequeueToIdleAsync_MidChain_UnblocksSuccessor()
|
||||
{
|
||||
// pred (queued, blocked) is removed from the queue in the middle of a chain;
|
||||
// successor was blocked on pred and must not be left wedged forever.
|
||||
var pred = await SeedTaskAsync(TaskStatus.Queued);
|
||||
var id = await SeedTaskAsync(TaskStatus.Queued, blockedBy: pred);
|
||||
var successor = await SeedTaskAsync(TaskStatus.Queued, blockedBy: id);
|
||||
|
||||
var result = await _sut.DequeueToIdleAsync(id, default);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var successorTask = await GetTaskAsync(successor);
|
||||
Assert.Equal(TaskStatus.Queued, successorTask.Status);
|
||||
Assert.Null(successorTask.BlockedByTaskId);
|
||||
}
|
||||
|
||||
// ─── StartPlanningAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -281,6 +281,7 @@ public class TaskRowViewModelNotificationsTests
|
||||
nameof(TaskRowViewModel.IsQueued),
|
||||
nameof(TaskRowViewModel.IsWaiting),
|
||||
nameof(TaskRowViewModel.StatusChipClass),
|
||||
nameof(TaskRowViewModel.CanRemoveFromQueue),
|
||||
}, raised);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user