Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f64ba5058 |
@@ -1,62 +1,5 @@
|
|||||||
# Changelog
|
# 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
|
## v2.12.0 — 2026-08-28
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,9 @@ 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".)
|
- **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>").
|
- **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>").
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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".
|
- **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.
|
- **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,10 +217,6 @@ public sealed record LaunchSpec(
|
|||||||
IReadOnlyList<string> Args,
|
IReadOnlyList<string> Args,
|
||||||
IReadOnlyDictionary<string, string> Env);
|
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 TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
|
||||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
||||||
|
|||||||
@@ -354,10 +354,6 @@
|
|||||||
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
|
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
|
||||||
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
||||||
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {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": {
|
"question": {
|
||||||
"title": "Claude fragt nach",
|
"title": "Claude fragt nach",
|
||||||
"placeholder": "Antwort eingeben…",
|
"placeholder": "Antwort eingeben…",
|
||||||
@@ -776,7 +772,7 @@
|
|||||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
"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}" },
|
"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}" },
|
"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 —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" },
|
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —" },
|
||||||
"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}" },
|
"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}" },
|
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||||
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||||
|
|||||||
@@ -354,10 +354,6 @@
|
|||||||
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
||||||
"enqueueFailed": "Queue failed: {0}",
|
"enqueueFailed": "Queue failed: {0}",
|
||||||
"queueRefreshFailed": "Couldn't refresh the queue: {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": {
|
"question": {
|
||||||
"title": "Claude is asking",
|
"title": "Claude is asking",
|
||||||
"placeholder": "Type your answer…",
|
"placeholder": "Type your answer…",
|
||||||
@@ -776,7 +772,7 @@
|
|||||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
"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}" },
|
"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}" },
|
"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 —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" },
|
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —" },
|
||||||
"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}" },
|
"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}" },
|
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||||
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
||||||
|
|||||||
@@ -158,10 +158,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// running. The exit code only exists in this process; the worker decides whether it was a
|
/// 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>
|
/// 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);
|
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
|
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -68,14 +68,6 @@ public sealed class PtyTerminalSession : IDisposable
|
|||||||
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
||||||
try
|
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)
|
foreach (var (key, value) in descriptor.Env)
|
||||||
Environment.SetEnvironmentVariable(key, value);
|
Environment.SetEnvironmentVariable(key, value);
|
||||||
|
|
||||||
|
|||||||
@@ -641,9 +641,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||||
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
|
=> 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)
|
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
||||||
|
|
||||||
|
|||||||
@@ -236,10 +236,6 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
return names.Count == 0 ? null : names;
|
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)
|
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_suppressSave = true;
|
_suppressSave = true;
|
||||||
@@ -250,7 +246,6 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
await LoadGlobalDefaultsAsync();
|
await LoadGlobalDefaultsAsync();
|
||||||
|
|
||||||
var cfg = await _worker.GetListConfigAsync(listId);
|
var cfg = await _worker.GetListConfigAsync(listId);
|
||||||
LastLoadedListConfig = cfg;
|
|
||||||
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
|
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
|
||||||
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (row is null || Dialogs is null || _services is null) return;
|
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 rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||||
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
if (vm.Deleted) await LoadAsync();
|
if (vm.Deleted) await LoadAsync();
|
||||||
else await RefreshRowAsync(row.Id);
|
else await RefreshRowAsync(row.Id);
|
||||||
@@ -409,8 +408,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (Dialogs is not null && _services is not null)
|
if (Dialogs is not null && _services is not null)
|
||||||
{
|
{
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
if (!vm.Saved && !vm.Deleted)
|
if (!vm.Saved && !vm.Deleted)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
|
||||||
private string? _blockedByTaskId;
|
private string? _blockedByTaskId;
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||||||
@@ -277,7 +276,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
&& !HasInteractiveSession;
|
&& !HasInteractiveSession;
|
||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
|
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
// "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 —
|
// 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
|
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ 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).
|
// 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;
|
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 string _displayTitle;
|
||||||
|
|
||||||
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
||||||
@@ -32,10 +26,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
||||||
[ObservableProperty] private bool _isSubmitPending;
|
[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>
|
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||||
public event Action<string>? ErrorReported;
|
public event Action<string>? ErrorReported;
|
||||||
|
|
||||||
@@ -46,12 +36,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||||
public event Action<string>? SubmitForReviewRequested;
|
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
|
/// <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
|
/// 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
|
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
|
||||||
@@ -157,16 +141,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
|
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 —
|
// 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.
|
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||||
|
|||||||
@@ -79,10 +79,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
// MissionControlView.OnNewSessionClicked).
|
// MissionControlView.OnNewSessionClicked).
|
||||||
public Action? BrowseForSessionRequested { get; set; }
|
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 UsagePillViewModel UsagePill { get; }
|
||||||
|
|
||||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
||||||
@@ -379,7 +375,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
|
() => DescribeAsync(() => launchSpec(taskId))));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -420,8 +416,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
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
|
// The 5h usage window is about to run out under this handler session: type the worker's warning
|
||||||
@@ -445,7 +440,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
pane.CloseRequested += CloseConPtySession;
|
pane.CloseRequested += CloseConPtySession;
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
|
||||||
ConPtySessions.Add(pane);
|
ConPtySessions.Add(pane);
|
||||||
pane.Start();
|
pane.Start();
|
||||||
}
|
}
|
||||||
@@ -501,41 +495,6 @@ 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)
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||||
{
|
{
|
||||||
if (!ConPtySessions.Contains(pane)) return;
|
if (!ConPtySessions.Contains(pane)) return;
|
||||||
@@ -543,7 +502,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
pane.CloseRequested -= CloseConPtySession;
|
pane.CloseRequested -= CloseConPtySession;
|
||||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
|
||||||
ConPtySessions.Remove(pane);
|
ConPtySessions.Remove(pane);
|
||||||
pane.Dispose();
|
pane.Dispose();
|
||||||
}
|
}
|
||||||
@@ -597,7 +555,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
c.CloseRequested -= CloseConPtySession;
|
c.CloseRequested -= CloseConPtySession;
|
||||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
|
||||||
c.Dispose();
|
c.Dispose();
|
||||||
}
|
}
|
||||||
ConPtySessions.Clear();
|
ConPtySessions.Clear();
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
public Func<string, Task>? ShowErrorAsync { 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 _name = "";
|
||||||
[ObservableProperty] private string _workingDir = "";
|
[ObservableProperty] private string _workingDir = "";
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
@@ -59,25 +56,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
|
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
|
||||||
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
|
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 ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
public const string DefaultIcon = "Folder";
|
public const string DefaultIcon = "Folder";
|
||||||
@@ -113,19 +91,6 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
|
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(
|
public async Task LoadAsync(
|
||||||
string listId,
|
string listId,
|
||||||
string name,
|
string name,
|
||||||
@@ -136,81 +101,43 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
string? icon = null,
|
string? icon = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ListConfigDto? cfg = null;
|
ListId = listId;
|
||||||
IsLoading = true;
|
Name = name;
|
||||||
_loadFailed = false;
|
IsManual = isManual;
|
||||||
try
|
FindingsTracked = findingsTracked;
|
||||||
{
|
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
||||||
ListId = listId;
|
// icon on the next save — fall back to the default instead.
|
||||||
Name = name;
|
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||||
IsManual = isManual;
|
WorkingDir = workingDir ?? "";
|
||||||
FindingsTracked = findingsTracked;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
// 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);
|
await Agent.LoadForListAsync(listId, ct);
|
||||||
cfg = Agent.LastLoadedListConfig;
|
var cfg = await _worker.GetListConfigAsync(listId);
|
||||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
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;
|
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
|
||||||
|
? await _worker.GetTicketSettingsAsync()
|
||||||
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
|
: null;
|
||||||
// up the rest of the modal. The dropdown just stays empty until this finishes.
|
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
|
||||||
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadTicketProjectsAsync(int? existingTicketProjectId)
|
|
||||||
{
|
|
||||||
_ticketProjectsLoaded = false;
|
|
||||||
TicketProjects.Clear();
|
TicketProjects.Clear();
|
||||||
try
|
if (TicketsAvailable)
|
||||||
{
|
{
|
||||||
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")));
|
TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone")));
|
||||||
foreach (var p in await _worker.GetTicketProjectsAsync())
|
foreach (var p in await _worker.GetTicketProjectsAsync())
|
||||||
TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}"));
|
TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}"));
|
||||||
|
|
||||||
SelectedTicketProject = existingTicketProjectId is int id
|
SelectedTicketProject = cfg?.TicketProjectId is int id
|
||||||
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
|
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
|
||||||
: TicketProjects[0];
|
: TicketProjects[0];
|
||||||
_ticketProjectsLoaded = true;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
{
|
||||||
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
|
SelectedTicketProject = null;
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanSave() => !IsLoading && !_loadFailed;
|
[RelayCommand]
|
||||||
private bool CanDelete() => !IsLoading;
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
await _worker.UpdateListAsync(new UpdateListDto(
|
await _worker.UpdateListAsync(new UpdateListDto(
|
||||||
@@ -222,11 +149,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
FindingsTracked,
|
FindingsTracked,
|
||||||
Icon));
|
Icon));
|
||||||
|
|
||||||
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
|
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
|
||||||
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
|
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
|
||||||
// and loaded -> always an explicit value, 0 for "— none —" to actively clear it. Never
|
// Never null here, or an explicit unlink would silently fail to reach the worker.
|
||||||
// null in that case, or an explicit unlink would silently fail to reach the worker.
|
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
||||||
var ticketProjectId = TicketsAvailable && _ticketProjectsLoaded ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
|
||||||
|
|
||||||
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
|
// 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".
|
// the worker as a real false rather than a "leave it alone".
|
||||||
@@ -239,7 +165,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
[RelayCommand]
|
||||||
private async Task DeleteAsync()
|
private async Task DeleteAsync()
|
||||||
{
|
{
|
||||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
Background="{DynamicResource Surface2Brush}"
|
Background="{DynamicResource Surface2Brush}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,0,0,1" Padding="8,3">
|
BorderThickness="0,0,0,1" Padding="8,3">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
ToolTip.Tip="{Binding DisplayTitle}"
|
ToolTip.Tip="{Binding DisplayTitle}"
|
||||||
@@ -25,12 +25,7 @@
|
|||||||
Content="{loc:Tr missionControl.submitForReview}"
|
Content="{loc:Tr missionControl.submitForReview}"
|
||||||
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
||||||
Command="{Binding SubmitForReviewCommand}" />
|
Command="{Binding SubmitForReviewCommand}" />
|
||||||
<Button Grid.Column="2" Classes="btn" Margin="0,0,6,0"
|
<Button Grid.Column="2" Classes="title-ctrl"
|
||||||
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}"
|
Command="{Binding CloseCommand}"
|
||||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
@@ -56,7 +55,6 @@ public partial class MissionControlView : UserControl
|
|||||||
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
||||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
_vm.BrowseForSessionRequested = null;
|
_vm.BrowseForSessionRequested = null;
|
||||||
_vm.ConfirmAsync = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_vm = DataContext as MissionControlViewModel;
|
_vm = DataContext as MissionControlViewModel;
|
||||||
@@ -66,59 +64,12 @@ public partial class MissionControlView : UserControl
|
|||||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
||||||
_vm.ConfirmAsync = ShowConfirmAsync;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RebuildOverviewGrid();
|
RebuildOverviewGrid();
|
||||||
UpdateQueueColumn();
|
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 OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
|
||||||
|
|
||||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr modals.about.title}" CloseCommand="{Binding CloseCommand}">
|
<ctl:ModalShell Title="{loc:Tr modals.about.title}" CloseCommand="{Binding CloseCommand}">
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
<ScrollViewer Padding="20,16" HorizontalScrollBarVisibility="Disabled">
|
||||||
<Grid Margin="20,16" RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
|
<Grid 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="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="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"/>
|
<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}"/>
|
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer>
|
<ScrollViewer Padding="20,2,20,8">
|
||||||
<StackPanel Margin="20,2,20,8">
|
<StackPanel>
|
||||||
<TextBlock Classes="meta" Margin="0,8"
|
<TextBlock Classes="meta" Margin="0,8"
|
||||||
Text="{loc:Tr modals.mergeHelper.empty}"
|
Text="{loc:Tr modals.mergeHelper.empty}"
|
||||||
IsVisible="{Binding !HasTasks}"/>
|
IsVisible="{Binding !HasTasks}"/>
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<ScrollViewer>
|
<ScrollViewer Padding="20,16">
|
||||||
<StackPanel Margin="20,16" Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
|
|
||||||
<TextBlock Classes="title" Text="{Binding TaskTitle, StringFormat='Merging: {0}'}" />
|
<TextBlock Classes="title" Text="{Binding TaskTitle, StringFormat='Merging: {0}'}" />
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Repo checklist -->
|
<!-- Repo checklist -->
|
||||||
<ScrollViewer>
|
<ScrollViewer Padding="20,2,20,8">
|
||||||
<ItemsControl Margin="20,2,20,8" ItemsSource="{Binding Repos}">
|
<ItemsControl ItemsSource="{Binding Repos}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
|
||||||
|
|||||||
@@ -134,8 +134,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<ScrollViewer>
|
<ScrollViewer Padding="20,16">
|
||||||
<StackPanel Margin="20,16">
|
<StackPanel>
|
||||||
<Border IsVisible="{Binding ConflictRows.Count}"
|
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||||
Background="{DynamicResource ErrorTintBrush}"
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly TicketClientFactory _ticketClients;
|
private readonly TicketClientFactory _ticketClients;
|
||||||
private readonly TicketImportService _ticketImport;
|
private readonly TicketImportService _ticketImport;
|
||||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||||
private readonly HandlerRoundState? _roundState;
|
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -113,8 +112,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
TicketPatStore ticketPat = null!,
|
TicketPatStore ticketPat = null!,
|
||||||
TicketClientFactory ticketClients = null!,
|
TicketClientFactory ticketClients = null!,
|
||||||
TicketImportService ticketImport = null!,
|
TicketImportService ticketImport = null!,
|
||||||
HandlerRestartCoordinator? handlerRestart = null,
|
HandlerRestartCoordinator? handlerRestart = null)
|
||||||
HandlerRoundState? roundState = null)
|
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -154,7 +152,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_ticketClients = ticketClients;
|
_ticketClients = ticketClients;
|
||||||
_ticketImport = ticketImport;
|
_ticketImport = ticketImport;
|
||||||
_handlerRestart = handlerRestart;
|
_handlerRestart = handlerRestart;
|
||||||
_roundState = roundState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||||
@@ -813,24 +810,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
|
_ = _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
|
// 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
|
// 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
|
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
||||||
|
|||||||
@@ -430,17 +430,6 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
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);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
return new TransitionResult(true, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||||
=> Task.CompletedTask;
|
=> 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)
|
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
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,110 +966,4 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
vm.ToggleLayoutCommand.Execute(null);
|
vm.ToggleLayoutCommand.Execute(null);
|
||||||
Assert.False(vm.IsFocusMode);
|
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,23 +41,6 @@ public class TaskRowContextMenuTests
|
|||||||
Assert.NotNull(row.SendToQueueDisabledReason);
|
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]
|
[Fact]
|
||||||
public void CancelDisabledReason_Null_While_Running()
|
public void CancelDisabledReason_Null_While_Running()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
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,23 +585,6 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
|
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 ───────────────────────────────────────────────
|
// ─── StartPlanningAsync ───────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -281,7 +281,6 @@ public class TaskRowViewModelNotificationsTests
|
|||||||
nameof(TaskRowViewModel.IsQueued),
|
nameof(TaskRowViewModel.IsQueued),
|
||||||
nameof(TaskRowViewModel.IsWaiting),
|
nameof(TaskRowViewModel.IsWaiting),
|
||||||
nameof(TaskRowViewModel.StatusChipClass),
|
nameof(TaskRowViewModel.StatusChipClass),
|
||||||
nameof(TaskRowViewModel.CanRemoveFromQueue),
|
|
||||||
}, raised);
|
}, raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user