Compare commits

...
16 Commits
Author SHA1 Message Date
ClaudeDo CI c9dd885fe0 docs(changelog): update for v2.16.0 2026-09-16 13:44:19 +00:00
mika kunsandClaude Opus 5 31dfaffc8b docs(changelog): update for v2.16.0
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:43:58 +02:00
Mika Kuns 09bcd696dd feat(ui): dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag
MergeTargetsDto/MergeTargets bekommen TargetDirty (via HasChangesAsync,
includeUntracked: false). Das Merge-Modal zeigt Warnung + Stash-Retry
sofort in InitializeAsync statt erst nach dem dirty_target-Fehlschlag;
der REVIEW-Block in der Detail-Pane zeigt denselben Hinweis neben
Approve. Der dirty_target-Rueckgabepfad bleibt als Absicherung bestehen.

Behebt Befund F6 aus docs/test-session-2026-09-16.md.
2026-09-16 15:43:39 +02:00
Mika Kuns 86cc309ba9 fix(ui): duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren
F4: DetailsIslandView/TasksIslandView hatten je eine view-lokale Confirm/Error-Dialog-Kopie,
teils mit hartkodiertem "Delete"-Knopf fuer jede Aktion (inkl. der Stash+Merge-Bestaetigung,
die nichts loescht). Beide Kopien entfernt; DetailsIslandViewModel/TasksIslandViewModel routen
Confirm/Error jetzt durch den geteilten Dialogs-Seam (IDialogService, propagiert vom Shell wie
schon bei Lists/Tasks) statt ueber eine vom Code-behind gesetzte Func-Property.

IDialogService.ConfirmAsync bekommt einen optionalen confirmLabel-Parameter (Default: lokalisiertes
"Confirm"); nur die echten Delete-Aktionen (Task loeschen, abgeschlossene Tasks leeren) uebergeben
das lokalisierte "Delete"-Label.

F5: "Cancel"/"Confirm"/"Delete"/"OK" und die Titel "Confirm"/"Error" durch Loc.T mit neuen Keys
unter "dialog.*" ersetzt (en.json + de.json). Mission Controls eigene Dialog-Kopie bleibt bestehen
(eigenes Fenster, WindowDialogService ist an den Haupt-Owner gebunden) — nur ihre Labels wurden
lokalisiert.

Out of scope (siehe Task-Beschreibung): die Nachrichtentexte der Dialoge bleiben unlokalisiert.
2026-09-16 15:43:38 +02:00
Mika KunsandClaude Sonnet 5 684b0115e5 feat(worker): preview_merge_set flags removed symbols and locale-key overlaps
Overlap detection was file-name-only: 9/15 tasks touching en.json/de.json in
run #316 all got flagged even though only one pair actually collided on a
key, and a removed method with no textual file conflict (#297 vs #286) went
unflagged entirely.

- removedSymbols per task entry: a line-pattern scan of `-` diff lines for
  C# method/property/field/type/event declarations (skips .axaml/.json).
- symbolWarnings at the set level: cross-references those names against
  every other task's `+` lines, catching same-symbol collisions across
  files that overlaps/subsets (both purely file-based) can't see.
- overlaps narrows to key-path level for .json files (base commit vs. task
  branch head, both sides parsed and flattened) so two tasks adding
  different locale keys to the same file no longer collide; unparsable
  JSON on either side falls back to the old file-level check.
- GitService.ShowFileAsync: `git show ref:path`, used to read a task
  branch's json content at its base/head commit.

Out of scope per the task: no Roslyn/parser, no AXAML analysis, and the
merge-helper prompt in PromptFiles.cs doesn't mention the new fields yet.

Build: dotnet build src/ClaudeDo.App (pulls in Ui+Data) + src/ClaudeDo.Worker, both clean.
Tests: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~MergeMcpTools|FullyQualifiedName~ExternalMcpToolSchema" -- 51 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:38 +02:00
Mika Kuns 1325ab9438 fix(ui): Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden
Befund F7 aus der Sichtpruefung 2026-09-16. TaskNumber war bereits im
WorktreeMaintenanceService-Join und in WorktreeOverviewRow vorhanden, fehlte
aber im Wire-DTO und in der UI-Kette; jetzt bis zur Row-/Conflict-Anzeige
durchgereicht (#<number> dimmed vor dem Titel, ausgeblendet bei 0). DIFF-Spalte
bekommt CharacterEllipsis-Trimming + Tooltip statt hartem Cut, Spalte von 80
auf 110px verbreitert (Header und Row synchron).
2026-09-16 15:43:38 +02:00
Mika Kuns 56161e4e9b feat(ui): Bilder aus der Zwischenablage ins interaktive Terminal einfuegen
Ctrl+V mit einem Bild in der Zwischenablage schreibt es als temporaeres
PNG unter ~/.claudeDo/tmp/ und fuegt den Pfad in den Composer ein, genau
wie ein Datei-Drop -- Claude liest die Datei selbst. Text-Paste laeuft
unveraendert ueber den bestehenden bracketed-paste-Pfad.

Avalonia 12.0.4 liest Clipboard-Bitmaps bereits selbst (IClipboard.
TryGetDataAsync + AsyncDataTransferExtensions.TryGetBitmapAsync), kein
Win32-P/Invoke noetig. Alte Tmp-Dateien (>24h) werden beim App-Start
aufgeraeumt (ClaudeDo.App/Program.cs), keine eigene Aufraeum-Infrastruktur.
2026-09-16 15:43:38 +02:00
Mika Kuns 4ff5383b35 fix(ui): Diff-Viewer scrollt jetzt auch horizontal synchron
VerticalScrollSync mirrort nur noch Y, X blieb pro Pane unabhaengig. Beide
Panes nutzen dieselbe Schrift/Breite, also X 1:1 mitspiegeln statt wie bei
Y ueber TargetOffset umzurechnen. Die Echo-Abbruchbedingung prueft jetzt
beide Achsen (vorher nur Y), sonst wuerde der neue X-Schreibvorgang seine
eigene Echo-Bedingung nie erfuellen und endlos weiterlaufen.

Klasse in ScrollSync umbenannt (DiffTextView + ConflictResolverView
mitgezogen), da sie nicht mehr nur vertikal synchronisiert.

Kein Headless-Test hinzugefuegt: DiffDispatcherGuardTests.cs dokumentiert
bereits, dass ein bootstrapptes Avalonia Application/Dispatcher in
ClaudeDo.Ui.Tests nach drei eskalierenden Versuchen als Sackgasse verifiziert
wurde (Dispatcher-Thread-Affinitaet kollidiert mit xUnits Worker-Pool ohne
Thread-Garantie; Avalonia.Headless.XUnit kollidiert mit xunit 2.9.3). Als
Finding avaloniaedit-ui-test-dead-end festgehalten. Sichtpruefung bleibt
offen (Side-by-side + Merge-Editor mit ueberbreiten Zeilen).
2026-09-16 15:43:37 +02:00
Mika Kuns 5cdfb14ff1 fix(ui): Ordner-Toggle in der Sidebar schluckt schnelle Klicks
ToggleFolderAsync war ein async [RelayCommand]; AsyncRelayCommand sperrt
sich waehrend des DB-Writes selbst, schnelle Klicks gingen verloren
(Befund F1, docs/test-session-2026-09-16.md).

Variante A: Toggle ist jetzt synchron (reiner UI-State), Persistierung
fire-and-forget mit einem CancellationTokenSource pro Ordner, damit ein
zweiter Klick vor dem ersten Schreibvorgang diesen abbricht - die DB
landet immer beim zuletzt sichtbaren Zustand, nie bei einem veralteten.
Gleiches Muster wie SaveTitleAsync/SaveDescriptionAsync in
DetailsIslandViewModel.

Gegenprueft: RemoveFromFolderAsync/CreateListAsync haben denselben
async-RelayCommand-Schnitt, sind aber Einzelklick-Aktionen (Kontextmenue/
Button), kein Rapid-Click-Ziel wie der Ordner-Toggle - bewusst nicht
umgebaut. Die Modal-oeffnenden Commands sind unkritisch, ein Modal
blockiert ohnehin.
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Sonnet 5 7452f6cfe4 fix(worker): preview_merge braucht einen runVerify-Parameter wie preview_merge_set
preview_merge baute bisher immer, sobald die Liste ein Verify-Kommando
konfiguriert hatte -- anders als das Geschwister-Tool preview_merge_set,
dessen runVerify-Parameter per Default false ist. Ein einzelnes preview_merge
im Merge-Helper-Recheck stiess dadurch einen ungewollten Scratch-Build an.

runVerify (Default false) jetzt symmetrisch bei preview_merge ergaenzt,
Description-Text und die Merge-Helper-Prompt-Doku angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Opus 5 c874bf7e52 fix(worker): depends_on-Nachfolger schon beim gelandeten Merge freigeben
Der Queue-Picker gatete eine task_dependencies-Kante auf status='done'.
review_task(skipVerify) laesst einen erfolgreich gemergten Task aber
bewusst auf WaitingForReview stehen, bis verify_merges einmal zentral
laeuft -- damit blockierte jeder Nachfolger ueber den gesamten Batch plus
Full-Suite-Verify hinweg, bei freien Slots.

Eine Kante gilt jetzt als erfuellt, wenn der Vorgaenger 'done' ist ODER
sein Merge gelandet ist (worktrees.state = 'merged'). TaskBlocking
(get_task/list_tasks/wait_for_task_change) und der UI-Wartehinweis folgen
derselben Regel, damit "blocked" nicht meldet, was der Picker claimt.

Abgeleitet: revert_merge/abort_merge setzen den Worktree auf Active und
blockieren wieder; reject_rerun/reject_park haben nie gemergt und bleiben
unerfuellt; ein Task ohne Worktree wird nur ueber 'done' erfuellt; ein
fehlgeschlagener verify_merges gibt Nachfolger frei, weil der Code so
oder so auf main liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Sonnet 5 50b6b56f87 feat(usage): poll the OAuth usage endpoint as fast as its measured rate limit allows
Measured 2026-09-16 against the live endpoint: a clean burst allows exactly 5
requests before a 429, with Retry-After: 300 (a flat 5-req/5-min quota, no
ratelimit-* headers on either the 200s or the 429s). Lowered
UsagePollIntervalActiveSeconds/UsagePollIntervalIdleSeconds default from
300/900 to 120 each (clamp floor 90, up from 60) — leaves ~2.5 requests of
headroom per window for manual refreshes and any other consumer of the same
OAuth token, unlike 60s which consumes the whole quota by itself. The quota
is a flat per-account limit, not tied to ClaudeDo's own running/idle state,
so both keys share the same default now (kept separate for config
compatibility).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika Kuns 9a41bed0f5 fix(ui): Worker-Neustart trifft nun den tatsaechlich laufenden Prozess
RestartWorkerService verliess sich auf WorkerLocator.Find(), das bei einem
Debug-Build ohne install.json in der Verzeichniskette auf die Registry
zurueckfaellt und den Pfad der *installierten* Worker.exe liefert. Der
passt nicht zum laufenden Debug-Prozess, also ueberspringt
KillMatchingWorkerProcesses ihn stillschweigend (Pfadvergleich) - der neu
gestartete Worker startet vom falschen Pfad und stirbt sofort am bereits
belegten Port, waehrend der echte Worker unberuehrt weiterlaeuft. Der
Neustart nimmt jetzt den Pfad des tatsaechlich laufenden
ClaudeDo.Worker-Prozesses als Quelle, mit dem Locator nur noch als
Fallback, wenn keiner laeuft.
2026-09-16 15:43:36 +02:00
Mika Kuns 103eb9a687 fix(ui): Shutdown terminates the app even with secondary windows open
ShutdownWorkerAndCloseApp only closed MainWindow via CloseWindow, relying
on ShutdownMode.OnMainWindowClose to bring the process down. That mode
backs off if any window cancels its own Closing event, and
MissionControlWindow always does (it hides instead of closing) - so the
process never actually exited while Mission Control, Cheat Sheets, or the
diff viewer were still open. Call IClassicDesktopStyleApplicationLifetime
.Shutdown() directly, which force-closes every window regardless of
per-window cancellation.
2026-09-16 15:43:36 +02:00
Mika Kuns 7ecb1fa0b5 Fix: some Reactive stuff 2026-09-16 15:43:36 +02:00
ClaudeDo CI 18394dd6b2 docs(changelog): update for v2.15.0 2026-09-11 09:43:40 +00:00
56 changed files with 1245 additions and 346 deletions
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## v2.16.0 — 2026-09-16
### Features
- dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag (09bcd696)
- preview_merge_set flags removed symbols and locale-key overlaps (684b0115)
- Bilder aus der Zwischenablage ins interaktive Terminal einfuegen (56161e4e)
- poll the OAuth usage endpoint as fast as its measured rate limit allows (50b6b56f)
### Fixes
- duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren (86cc309b)
- Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden (1325ab94)
- Diff-Viewer scrollt jetzt auch horizontal synchron (4ff5383b)
- Ordner-Toggle in der Sidebar schluckt schnelle Klicks (5cdfb14f)
- preview_merge braucht einen runVerify-Parameter wie preview_merge_set (7452f6cf)
- depends_on-Nachfolger schon beim gelandeten Merge freigeben (c874bf7e)
- Worker-Neustart trifft nun den tatsaechlich laufenden Prozess (9a41bed0)
- Shutdown terminates the app even with secondary windows open (103eb9a6)
### Documentation
- update for v2.16.0 (31dfaffc)
- update for v2.15.0 (18394dd6)
## v2.15.0 — 2026-09-11
### Features
- Stash-Option fuer uncommittete Aenderungen im Ziel-Tree (251b70ce)
### Documentation
- update for v2.14.0 (8e2d37e6)
## v2.14.0 — 2026-09-10
### Features
+3 -3
View File
@@ -184,9 +184,9 @@ detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDet
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
Wired into `ExternalMcpService`: both `preview_merge` (single task) and `preview_merge_set` only
request a verify run when their `runVerify` parameter is explicitly set (default `false`) — a
preview never starts a build unasked. The Hub's
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
+6 -1
View File
@@ -21,6 +21,11 @@ authenticated with the Bearer access token Claude Code itself keeps fresh at
Because the endpoint is undocumented and can change without notice, **every consumer
fails open**. That is the single most important invariant here.
**Measured 2026-09-16** (clean burst against the live endpoint, Worker otherwise idle): exactly
5 requests succeed, the 6th 429s with `Retry-After: 300` — a flat 5-requests-per-5-minute quota,
no `ratelimit-*`/`x-ratelimit-*` headers on either the 200s or the 429. The quota is per-account,
not per-process, so a manual refresh or another consumer of the same OAuth token shares it.
## Components (`Usage/`)
| Type | Role |
@@ -28,7 +33,7 @@ fails open**. That is the single most important invariant here.
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (default 120 each, clamped to min 90 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
+2
View File
@@ -42,6 +42,8 @@ sealed class Program
ClaudeDoDbContext.MigrateAndConfigure(db);
}
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
try
{
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
+10
View File
@@ -558,6 +558,16 @@ public sealed class GitService
return exitCode == 0 ? stdout : null;
}
/// <summary>
/// Content of <paramref name="path"/> at <paramref name="revision"/> via `git show revision:path`.
/// Null when the path doesn't exist at that revision (new, deleted, or renamed file).
/// </summary>
public async Task<string?> ShowFileAsync(string repoDir, string revision, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $"{revision}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
+1 -1
View File
@@ -523,7 +523,7 @@ public static class PromptFiles
- Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on.
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it (default runVerify=false — this is just a conflict recheck, not a build; the verify command still runs once via verify_merges after the last merge).
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
+4 -2
View File
@@ -72,7 +72,8 @@ public record WorktreeOverviewDto(
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
bool PathExistsOnDisk,
int TaskNumber = 0);
public record ForceRemoveResultDto(bool Removed, string? Reason);
@@ -91,7 +92,8 @@ public record MergePreviewDto(
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
public record MergeTargetsDto(
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
bool TargetDirty = false);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
+10 -1
View File
@@ -328,6 +328,7 @@
"worktree": "Worktree",
"reviewCombinedDiff": "Kombinierten Diff prüfen",
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
"approveMerge": "Genehmigen & Mergen",
"park": "Parken",
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
@@ -805,6 +806,14 @@
"done": "Fertig"
}
},
"dialog": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen",
"ok": "OK",
"confirmTitle": "Bestätigen",
"errorTitle": "Fehler"
},
"shell": {
"menu": {
"help": "Hilfe",
@@ -851,7 +860,7 @@
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
+10 -1
View File
@@ -328,6 +328,7 @@
"worktree": "Worktree",
"reviewCombinedDiff": "Review Combined Diff",
"openDiffHint": "Open the diff to enable merge",
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
"approveMerge": "Approve & Merge",
"park": "Park",
"parkTip": "Set aside — back to Idle, keeps the worktree",
@@ -805,6 +806,14 @@
"done": "Done"
}
},
"dialog": {
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"ok": "OK",
"confirmTitle": "Confirm",
"errorTitle": "Error"
},
"shell": {
"menu": {
"help": "Help",
@@ -851,7 +860,7 @@
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "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.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "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.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
+1 -1
View File
@@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail →
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put
new editor boilerplate there rather than copying it a third time.
## Services
@@ -0,0 +1,45 @@
using System;
using System.IO;
using ClaudeDo.Data;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
/// the same way a file drop is, then read by Claude like any other file.
/// </summary>
public static class ClipboardImageTempStore
{
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
/// <summary>A fresh, collision-free path for one pasted image.</summary>
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
/// app startup, not a running cleanup service.</summary>
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
/// instead of the real app data root.</summary>
internal static string NewPngPath(string dir, DateTime utcNow) =>
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
/// explicitly instead of the real app data root.</summary>
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
{
if (!System.IO.Directory.Exists(dir)) return;
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
{
try
{
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
File.Delete(file);
}
catch (IOException) { /* best effort */ }
catch (UnauthorizedAccessException) { /* best effort */ }
}
}
}
+3 -2
View File
@@ -28,8 +28,9 @@ public interface IDialogService
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
Task<bool> ConfirmAsync(string message);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
/// <summary>Modal error notice with a single dismiss button.</summary>
Task ShowErrorAsync(string message);
@@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Set by shell so DeleteTaskCommand can remove from list
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
// Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go
// through the one shared dialog implementation instead of a view-local copy.
public IDialogService? Dialogs { get; set; }
// ── Review ──────────────────────────────────────────────────────────────
[ObservableProperty]
@@ -728,6 +726,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
await Merge.RefreshMergePreviewAsync();
@@ -778,6 +777,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
}
@@ -824,6 +824,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
}
@@ -1042,9 +1043,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
if (Task == null) return;
var row = Task;
if (ConfirmAsync != null)
if (Dialogs != null)
{
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
@@ -1060,14 +1061,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
// Belt and braces: the connection can drop between the CanExecute check and
// this call, so a stray throw here must surface, not vanish silently.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
return;
}
if (!deleted)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(error ?? "Delete failed.");
if (Dialogs != null)
await Dialogs.ShowErrorAsync(error ?? "Delete failed.");
return;
}
if (DeleteFromList != null)
@@ -1216,10 +1217,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ResetAndRetryAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
if (Dialogs == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
if (!ok) return;
@@ -1279,31 +1280,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Nothing landed -- the target working dir has uncommitted changes (often another
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
// restores the changes itself once the merge is through.
if (result?.Status == "dirty_target" && ConfirmAsync != null
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
if (result?.Status == "dirty_target" && Dialogs != null
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
{
ApproveOp.Report(Loc.T("ops.merge.merging"));
result = await _worker.ApproveReviewAsync(
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
}
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
if (result?.Status == "dirty_target" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
else if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
// Nothing landed -- the merge was refused before touching the repo because the
// branch would have overwritten an untracked file in the target working directory.
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
}
catch (Exception ex)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
}
finally
{
@@ -1348,7 +1349,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
}
}
@@ -1379,9 +1380,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async System.Threading.Tasks.Task ResetReviewAsync()
{
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
if (!ok) return;
try { await _worker.ResetTaskAsync(Task.Id); }
@@ -622,16 +622,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await MoveToFolderAsync(row, null);
}
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
// stale one racing ahead of it.
[RelayCommand]
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
private void ToggleFolder(ListFolderNavItemViewModel? folder)
{
if (folder is null) return;
folder.IsCollapsed = !folder.IsCollapsed;
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
var cts = new CancellationTokenSource();
_folderCollapseCts[folder.Id] = cts;
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
}
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
ct.ThrowIfCancellationRequested();
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
// The fold itself already happened in the UI; only its persistence failed.
@@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
// Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working
// dir is visible before anyone clicks, not just after the merge modal refuses it.
[ObservableProperty] private bool _targetDirty;
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
public OperationStatus PreviewOp { get; } = new();
@@ -141,6 +145,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
MergePreviewText = "";
MergeIsClean = false;
MergeIsConflict = false;
TargetDirty = false;
SyncWorktree(null, null, null, null, null);
SyncTaskContext(null, null, false);
SyncChildOutcomes(false, 0);
@@ -769,12 +769,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
// keeps blocking rather than being silently treated as satisfied.
// keeps blocking rather than being silently treated as satisfied. A landed merge satisfies
// the edge too (skipVerify parks a merged task in WaitingForReview until the batch verify);
// the external cache carries no worktree state, so a cross-list predecessor still shows as
// waiting until it reaches Done.
private static bool IsUnmet(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
if (rowsById.TryGetValue(id, out var p))
return p.Status != TaskStatus.Done && p.WorktreeState != WorktreeState.Merged;
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
return true;
}
@@ -1205,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// block removing a completed planning parent together with its done children.
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1335,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task DeleteTaskAsync(TaskRowViewModel? row)
{
if (row is null || row.HasInteractiveSession || _worker is null) return;
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1649,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
{
if (ConfirmAsync is null) return;
var ok = await ConfirmAsync(
if (Dialogs is null) return;
var ok = await Dialogs.ConfirmAsync(
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
"Dequeue them and discard the planning session?");
if (!ok) return;
@@ -1659,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
{
if (ConfirmAsync is null) return;
await ConfirmAsync(
if (Dialogs is null) return;
await Dialogs.ConfirmAsync(
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
"Cancel them first, then try again.");
}
}
/// <summary>
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
/// </summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
@@ -1,3 +1,5 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using System;
using System.Threading;
@@ -66,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
// Set by MainWindow so a reveal can bring the main window to the foreground.
public Action? BringToFront { get; set; }
// Single dialog seam (set by MainWindow); propagated to the lists island.
// Single dialog seam (set by MainWindow); propagated to the islands.
private IDialogService? _dialogs;
public IDialogService? Dialogs
{
@@ -76,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
if (Details is not null) Details.Dialogs = value;
}
}
@@ -655,7 +658,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
// the registry's InstallLocation otherwise — on a debug build launched from the repo
// there's no install.json above it, so it returns the *installed* worker's path. That
// never matches the running debug process, so KillMatchingWorkerProcesses silently
// skips it (it only kills a process whose path equals the given exe) and this either
// launches a second worker that immediately dies on the already-bound port, or leaves
// the real one running untouched while nothing visibly changes.
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
KillMatchingWorkerProcesses(exe);
@@ -665,6 +676,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
}
private static string? RunningWorkerExePath()
{
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try
{
if (p.MainModule?.FileName is { } path) return path;
}
catch { /* inaccessible */ }
finally { p.Dispose(); }
}
return null;
}
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
private static void KillMatchingWorkerProcesses(string exe)
@@ -686,10 +711,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}
}
// Set by MainWindow: force-closes the window without re-triggering its own close
// confirmation (the shutdown dialog above already confirmed).
public Action? CloseWindow { get; set; }
// Test seam: overrides the real kill-worker + close-window body.
internal Action? ShutdownOperation { get; set; }
@@ -706,7 +727,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
var exe = _workerLocator.Find();
if (exe is not null) KillMatchingWorkerProcesses(exe);
CloseWindow?.Invoke();
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
// every open window to close and backs off if any of them cancels — and
// MissionControlWindow always cancels its own close (it hides instead, see
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
// force-closes every window, ignoring that cancellation.
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.Shutdown();
}
private bool _updatingClaudeCli;
@@ -76,6 +76,14 @@ public sealed partial class MergeModalViewModel : ViewModelBase
SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: Branches.FirstOrDefault();
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
// of making the user hit the "dirty_target" refusal first.
if (targets.TargetDirty)
{
WarningMessage = Loc.T("vm.merge.targetDirty");
ShowStashRetry = true;
}
}
catch (Exception ex)
{
@@ -16,6 +16,7 @@ public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Verify
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
{
[ObservableProperty] private string _taskId = "";
[ObservableProperty] private int _taskNumber;
[ObservableProperty] private string _taskTitle = "";
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
[ObservableProperty] private string _listId = "";
@@ -49,6 +50,9 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
public bool IsKept => State == WorktreeState.Kept;
public bool IsRunning => TaskStatus == TaskStatus.Running;
// 0 = row without an allocated number (test seeds); a bare "#0" would be meaningless.
public bool ShowNumberBadge => TaskNumber > 0;
private static string FormatAge(TimeSpan ts)
{
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
@@ -366,7 +370,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
{
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
TaskId = d.TaskId, TaskNumber = d.TaskNumber, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
ListId = d.ListId, ListName = d.ListName,
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
@@ -78,8 +78,8 @@ public partial class ConflictResolverView : Window
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
// gutter buttons only have to be repositioned once per scroll.
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters);
new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters);
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
@@ -75,9 +75,11 @@ internal static class DiffEditorSetup
}
/// <summary>
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
/// the text views own the instance.
/// Keeps a set of editors scrolled to the same position, vertically always and horizontally
/// whenever the panes share the same content width (no per-pane offset conversion, unlike
/// <see cref="TargetOffset"/> for Y). Construction wires the panes; there is nothing to dispose,
/// and nothing to keep a reference to — the event subscriptions on the text views own the
/// instance.
/// </summary>
/// <remarks>
/// <para>
@@ -96,7 +98,7 @@ internal static class DiffEditorSetup
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
/// </para>
/// </remarks>
internal sealed class VerticalScrollSync
internal sealed class ScrollSync
{
private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers;
@@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
{
_editors = editors;
_viewers = new ScrollViewer?[editors.Length];
@@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync
_syncing = true;
try
{
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
for (var i = 0; i < _editors.Length; i++)
{
if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
// cleared again, so this comparison — not the flag — is what ends the loop.
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(viewer.Offset.X, y);
// cleared again, so this comparison — not the flag — is what ends the loop. Both
// axes must be checked: writing only when Y moved left X's own echo unguarded,
// which is how it looped forever once X was written below.
if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(x, y);
}
}
finally { _syncing = false; }
@@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
ReloadFile();
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines);
new ScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines);
}
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -43,13 +45,54 @@ public partial class InteractiveTerminalView : UserControl
if (DataContext is not InteractiveTerminalViewModel vm) return;
e.Handled = true;
_ = PasteAndReportAsync(vm);
_ = PasteAndReportAsync(this, vm);
}
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
// A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written
// to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file
// itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow.
// Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste
// failure — never silently fall back to a text paste of the same clipboard.
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm)
{
if (!await vm.PasteAsync())
Avalonia.Media.Imaging.Bitmap? bitmap = null;
try
{
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
if (bitmap is null)
{
if (!await vm.PasteAsync()) vm.ReportSendFailed();
return;
}
var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow);
Directory.CreateDirectory(ClipboardImageTempStore.Directory);
bitmap.Save(path);
if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path })))
vm.ReportSendFailed();
}
catch (Exception)
{
vm.ReportSendFailed();
}
finally
{
bitmap?.Dispose();
}
}
private static async System.Threading.Tasks.Task<Avalonia.Media.Imaging.Bitmap?> TryGetClipboardBitmapAsync(TopLevel? topLevel)
{
if (topLevel?.Clipboard is not { } clipboard) return null;
try
{
using var data = await clipboard.TryGetDataAsync();
return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
}
catch (Exception)
{
return null;
}
}
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
@@ -327,6 +327,10 @@
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.targetDirty}"
TextWrapping="Wrap"
Foreground="{DynamicResource AmberBrush}"
IsVisible="{Binding Merge.TargetDirty}" />
</StackPanel>
<StackPanel Spacing="0">
@@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.ShowErrorAsync = ShowErrorDialogAsync;
}
}
@@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
}
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = new Window
{
Title = "Error",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { ok }
}
}
}
};
ok.Click += (_, _) => dialog.Close();
await dialog.ShowDialog(owner);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
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;
}
}
@@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
}
};
@@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.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;
}
// ── Custom ghost drag ────────────────────────────────────────────────────
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
@@ -80,11 +80,6 @@ public partial class MainWindow : Window
WindowState = WindowState.Normal;
Activate();
};
vm.CloseWindow = () =>
{
_forceClose = true;
Close();
};
if (vm.Tasks is not null)
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
}
@@ -8,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -73,20 +74,21 @@ public partial class MissionControlView : UserControl
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
// through WindowDialogService: it's owned by the main window, so showing it while Mission
// Control (a separate Window) is focused would put it behind Mission Control.
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 cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Title = Loc.T("dialog.confirmTitle"),
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
@@ -70,6 +70,11 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding WarningMessage}"
Foreground="{DynamicResource AmberBrush}"
TextWrapping="Wrap"
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
@@ -58,13 +58,18 @@
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
<Grid ColumnDefinitions="Auto,*,90,90,110,80">
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding IsActive}"
IsVisible="{Binding IsActive}"/>
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
IsVisible="{Binding ShowNumberBadge}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1" Classes="title" Text="{Binding TaskTitle}"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
<TextBlock Classes="meta" Text="•"
@@ -85,7 +90,8 @@
HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding State}"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding DiffStat}"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
</Grid>
</Border>
@@ -146,8 +152,11 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Text="{Binding TaskTitle}"/>
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
IsVisible="{Binding ShowNumberBadge}"/>
<TextBlock Classes="meta" Text="{Binding TaskTitle}"/>
</StackPanel>
<Button Grid.Column="1" Classes="btn"
Content="{loc:Tr modals.worktreesOverview.resolve}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
@@ -159,7 +168,7 @@
</StackPanel>
</Border>
<!-- Column headers -->
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
<Grid ColumnDefinitions="Auto,*,90,90,110,80" Margin="12,0,12,4">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
+10 -9
View File
@@ -6,6 +6,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
@@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
{
vm.Worktrees.ConfirmAsync = ConfirmAsync;
vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message);
var dlg = new SettingsModalView { DataContext = vm };
vm.RequestRepoImport = () =>
{
@@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService
{
var dlg = new ListSettingsModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
vm.ConfirmAsync = ConfirmAsync;
vm.ConfirmAsync = message => ConfirmAsync(message);
vm.ShowErrorAsync = ShowErrorAsync;
await dlg.ShowDialog(_owner);
}
@@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService
_diffViewerWindow = diffDlg;
diffDlg.Show(_owner);
};
vm.ConfirmAction = ConfirmAsync;
vm.ConfirmAction = message => ConfirmAsync(message);
if (Shell is { } shell)
{
vm.ResolveMergeVm = shell.ResolveMergeVm;
@@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService
_cheatSheets.Activate();
}
public Task<bool> ConfirmAsync(string message)
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = NoticeWindow("Confirm", 380, message,
var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message,
new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService
public Task ShowErrorAsync(string message)
{
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = NoticeWindow("Error", 360, message,
var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 };
var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message,
new StackPanel
{
Orientation = Orientation.Horizontal,
+6 -3
View File
@@ -51,7 +51,7 @@ subfolder within their area; the namespace stays the area namespace.
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't satisfied" — satisfied = `status = 'done'` **or** a landed merge (`worktrees.state = 'merged'`), so a predecessor merged with `skipVerify` and parked in `WaitingForReview` until the batch `verify_merges` no longer stalls its successors (doubly negated on purpose: zero edges stays eligible, all edges must be satisfied, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
@@ -67,7 +67,7 @@ not conflated.
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency's merge has landed — `Done`, or `worktrees.state = 'merged'` while the batch `verify_merges` is still pending. A Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked; `revert_merge`/`abort_merge` put the worktree back to `Active` and re-block the successors; a failing `verify_merges` does **not** (the code is on the target branch either way). See `QueuePicker`, `TaskStateService.SetDependsOnAsync`. `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
@@ -254,7 +254,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
here is hand-edit only.
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
- `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (120 each, clamped to
min 90 on load) — measured 2026-09-16: the endpoint allows exactly 5 requests per 5-minute
window, then 429s with `Retry-After: 300`; 120s leaves margin for manual refreshes and other
consumers of the same OAuth token, 60s eats the whole quota (see [usage-monitoring](../../docs/explore-notes/usage-monitoring.md)).
- `online_inbox` — `enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.claudeDo/online-inbox.token`.
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
+15 -6
View File
@@ -46,16 +46,25 @@ public sealed class WorkerConfig
/// <summary>
/// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum
/// of 60s on load — the endpoint rate-limits (429) on tighter polling.
/// of 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows
/// exactly 5 requests before a 429, with <c>Retry-After: 300</c> (a flat 5-req/5-min quota,
/// no <c>ratelimit-*</c> headers). 120s leaves ~2.5 requests of headroom per 5-minute window
/// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the
/// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429
/// (see commit 2700c3d8). Same default as <see cref="UsagePollIntervalIdleSeconds"/>: the
/// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle
/// split buys nothing safety-wise. The two config keys stay separate for compatibility.
/// </summary>
[JsonPropertyName("usage_poll_interval_active_seconds")]
public int UsagePollIntervalActiveSeconds { get; set; } = 300;
public int UsagePollIntervalActiveSeconds { get; set; } = 120;
/// <summary>
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 60s on load.
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on
/// load. Same default as <see cref="UsagePollIntervalActiveSeconds"/> — see that doc for the
/// measured rate limit this is sized against.
/// </summary>
[JsonPropertyName("usage_poll_interval_idle_seconds")]
public int UsagePollIntervalIdleSeconds { get; set; } = 900;
public int UsagePollIntervalIdleSeconds { get; set; } = 120;
/// <summary>Base URL of the Bandel ticket-system API. Null = ticket integration off entirely.</summary>
[JsonPropertyName("ticket_api_base_url")]
@@ -88,8 +97,8 @@ public sealed class WorkerConfig
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
cfg.UsagePollIntervalActiveSeconds = Math.Max(60, cfg.UsagePollIntervalActiveSeconds);
cfg.UsagePollIntervalIdleSeconds = Math.Max(60, cfg.UsagePollIntervalIdleSeconds);
cfg.UsagePollIntervalActiveSeconds = Math.Max(90, cfg.UsagePollIntervalActiveSeconds);
cfg.UsagePollIntervalIdleSeconds = Math.Max(90, cfg.UsagePollIntervalIdleSeconds);
return cfg;
}
+17 -10
View File
@@ -216,14 +216,19 @@ public sealed class ExternalMcpService
.Distinct()
.ToList();
var dependencyStatuses = new Dictionary<string, TaskStatus>();
var predecessors = new Dictionary<string, (TaskStatus Status, bool Merged)>();
if (dependencyIds.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
dependencyStatuses = await ctx.Tasks.AsNoTracking()
predecessors = await ctx.Tasks.AsNoTracking()
.Where(t => dependencyIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
.Select(t => new
{
t.Id,
t.Status,
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
})
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
}
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
@@ -239,7 +244,7 @@ public sealed class ExternalMcpService
}
else
{
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, predecessors);
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
}
}
@@ -290,13 +295,14 @@ public sealed class ExternalMcpService
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
"list/global default (normally sonnet).")]
string? model = null,
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
[Description("Id of a task whose merge must have landed before the picker will claim this one, even once " +
"queued (Done, or merged and still awaiting the batch verify). Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. " +
"Rejected if it doesn't exist, is this task's own id, or would create a " +
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one. Use this when a " +
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
string[]? dependsOnTaskIds = null,
@@ -460,12 +466,13 @@ public sealed class ExternalMcpService
string? title = null,
string? description = null,
string? commitType = null,
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
[Description("Id of a task whose merge must have landed (Done, or merged and still awaiting the batch " +
"verify) before the picker will claim this one. Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
"is this task's own id, or would create a dependency cycle.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one, replacing the " +
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
"a task wires together, or verifies, the results of several independent tasks.")]
string[]? dependsOnTaskIds = null,
+309 -43
View File
@@ -1,6 +1,8 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -61,7 +63,14 @@ public sealed record MergePreviewSetEntryDto(
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
// (verification/audit/research task) apart from a task that silently failed to commit, both
// of which otherwise show IsEmpty=true with no other distinguishing signal.
bool ExpectsNoDiff = false);
bool ExpectsNoDiff = false,
// Top-level C# declarations (methods/properties/fields/types/events) this task's diff removes
// -- a line-pattern scan over `-` lines, not a parser: false positives are possible, a missed
// removal is not the intent. Skips .axaml/.json. Null for an entry with Error set. A name
// another task in the same set still touches on a `+` line surfaces in SymbolWarnings below.
IReadOnlyList<RemovedSymbolDto>? RemovedSymbols = null);
public sealed record RemovedSymbolDto(string Name, string File);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
@@ -71,9 +80,16 @@ public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds,
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
// RemovedByTaskId's diff removes Symbol (in RemovedInFile); ReferencedByTaskId's diff still touches
// that name on a `+` line elsewhere -- no textual file conflict, so Overlaps alone misses it (e.g.
// one task deletes a method, a sibling task's edit in a different file still calls it).
public sealed record SymbolReferenceWarningDto(
string Symbol, string RemovedInFile, string RemovedByTaskId, int RemovedByNumber,
string ReferencedByTaskId, int ReferencedByNumber, string ReferencedInFile);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets);
IReadOnlyList<SubsetRelationDto> Subsets, IReadOnlyList<SymbolReferenceWarningDto> SymbolWarnings);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
@@ -446,26 +462,30 @@ public sealed class MergeMcpTools
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
"from disk." + McpToolDocs.ProgressHint)]
"branch even when the preview is clean. runVerify=false (default) never builds. runVerify=true additionally " +
"builds/tests a clean preview in a scratch worktree (still without touching the real working tree), if the " +
"list has a verify command configured — verifyExitCode 0 means it built clean, non-zero or -1 " +
"(timeout/failed to start) means it doesn't, with the tail of its output in verifyOutputTail; " +
"verifyExitCode/verifyDurationMs/verifyOutputTail stay null without runVerify=true or without a configured " +
"verify command. isEmpty=true means the task's review range contributed nothing; check that flag rather " +
"than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet " +
"collide on the one file that matters (always empty for a worktree-less handler task, which has no fork " +
"point). Throws if the task has neither an active worktree nor a handler commit range, or the list's " +
"working directory is missing from disk." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
[Description("Branch to preview against; defaults to the repo's current branch.")]
string? targetBranch = null,
[Description("true: also run the verify command (if configured), building in a scratch worktree. " +
"false (default): no build.")]
bool runVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
var (preview, behind, _, isEmpty, staleFiles, _, _, _, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
}
@@ -473,16 +493,21 @@ public sealed class MergeMcpTools
[McpServerTool, Description(
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"be previewed (it is then left out of the overlap/subset/symbol computation). overlaps names, for each " +
"file touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields " +
"no overlaps; for a .json file this is narrowed to nested-key-path level (parsing both the base commit " +
"and the task branch's head), so two tasks adding different keys to the same locale file are not flagged " +
"and only a genuinely shared key is — unparsable JSON on either side falls back to the plain file-level " +
"check. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
"since builds run one at a time." + McpToolDocs.ProgressHint)]
"worth checking before merging both. removedSymbols (per task entry) lists C# methods/properties/fields/" +
"types/events that task's diff deletes (a line-pattern scan, not a parser — false positives possible, " +
"skips .axaml/.json); symbolWarnings then cross-references those names against every OTHER task's added " +
"lines in the same set, catching a same-symbol collision even when the two tasks touch entirely different " +
"files. IMPORTANT: none of overlaps/subsets/symbolWarnings is a safety guarantee, and as with preview_merge " +
"a clean result does not mean the merge builds. runVerify=false (default) never builds — set it true to " +
"also run each task's list's verify command in a scratch worktree per entry (same fields as preview_merge); " +
"this can take a long time across many tasks, since builds run one at a time." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
@@ -501,18 +526,37 @@ public sealed class MergeMcpTools
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
var repoPathByTask = new Dictionary<string, string>();
var baseRefByTask = new Dictionary<string, string>();
var headRefByTask = new Dictionary<string, string?>();
var diffLinesByTask = new Dictionary<string, List<DiffLine>>();
var removedSymbolsByTask = new Dictionary<string, IReadOnlyList<RemovedSymbolDto>>();
for (var i = 0; i < taskIds.Count; i++)
{
var taskId = taskIds[i];
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff));
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff, repoPath, baseRef, headRef) =
await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
repoPathByTask[taskId] = repoPath;
baseRefByTask[taskId] = baseRef;
headRefByTask[taskId] = headRef;
var diff = headRef is null
? await _git.GetBranchDiffAsync(repoPath, baseRef, ct: cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseRef, headRef, ct: cancellationToken);
var diffLines = ParseDiffLines(diff);
diffLinesByTask[taskId] = diffLines;
var removedSymbols = ExtractRemovedSymbols(diffLines);
removedSymbolsByTask[taskId] = removedSymbols;
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number,
expectsNoDiff, removedSymbols));
}
catch (InvalidOperationException ex)
{
@@ -525,23 +569,243 @@ public sealed class MergeMcpTools
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
}
var overlaps = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
.Select(g =>
{
var ids = g.Select(x => x.TaskId).Distinct().ToList();
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
})
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
var overlaps = await ComputeOverlapsAsync(filesByTask, numbersByTask, repoPathByTask, baseRefByTask, headRefByTask, cancellationToken);
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
var symbolWarnings = FindSymbolWarnings(removedSymbolsByTask, numbersByTask, diffLinesByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets);
return new MergePreviewSetResultDto(entries, overlaps, subsets, symbolWarnings);
}
// Cross-references each task's removed symbols (Teil 1) against every OTHER task's added
// lines in the same set -- a same-named symbol collision that overlaps/subsets (both purely
// file-based) can never catch, since the two tasks may not share a single file.
private static List<SymbolReferenceWarningDto> FindSymbolWarnings(
Dictionary<string, IReadOnlyList<RemovedSymbolDto>> removedSymbolsByTask,
Dictionary<string, int> numbersByTask,
Dictionary<string, List<DiffLine>> diffLinesByTask)
{
var warnings = new List<SymbolReferenceWarningDto>();
foreach (var (taskId, removed) in removedSymbolsByTask)
{
foreach (var symbol in removed)
{
foreach (var (otherTaskId, diffLines) in diffLinesByTask)
{
if (otherTaskId == taskId) continue;
var hit = diffLines.FirstOrDefault(l => l.Sign == '+' && ContainsWord(l.Text, symbol.Name));
if (hit.File is not null)
warnings.Add(new SymbolReferenceWarningDto(
symbol.Name, symbol.File, taskId, numbersByTask[taskId],
otherTaskId, numbersByTask[otherTaskId], hit.File));
}
}
}
return warnings
.OrderBy(w => w.Symbol, StringComparer.Ordinal)
.ThenBy(w => w.RemovedByTaskId, StringComparer.Ordinal)
.ThenBy(w => w.ReferencedByTaskId, StringComparer.Ordinal)
.ToList();
}
// File-level overlap for everything except .json; for .json, narrows to nested-key-path
// collisions between the tasks that touch it (Teil 2) -- unless either side fails to parse,
// in which case it falls back to the plain file-level check for that file.
private async Task<List<FileOverlapDto>> ComputeOverlapsAsync(
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask,
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
Dictionary<string, string?> headRefByTask, CancellationToken ct)
{
var overlaps = new List<FileOverlapDto>();
var byFile = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase);
foreach (var group in byFile)
{
var taskIds = group.Select(x => x.TaskId).Distinct().ToList();
if (taskIds.Count <= 1) continue;
if (!group.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask));
continue;
}
var colliding = await FindJsonKeyCollisionTasksAsync(
group.Key, taskIds, repoPathByTask, baseRefByTask, headRefByTask, ct);
if (colliding is null)
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask)); // unparsable -- fall back
else if (colliding.Count > 1)
overlaps.Add(MakeOverlap(group.Key, colliding, numbersByTask));
}
return overlaps.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase).ToList();
}
private static FileOverlapDto MakeOverlap(string file, IReadOnlyList<string> taskIds, Dictionary<string, int> numbersByTask)
=> new(file, taskIds, taskIds.Select(id => numbersByTask[id]).ToList());
// Null = give up and fall back to file-level (JSON unparsable on some side for some task).
// Otherwise: the subset of taskIds whose changed/added/removed key paths in `file` intersect
// with at least one other task's -- two tasks touching disjoint keys in the same file never appear.
private async Task<List<string>?> FindJsonKeyCollisionTasksAsync(
string file, List<string> taskIds,
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
Dictionary<string, string?> headRefByTask, CancellationToken ct)
{
var keysByTask = new Dictionary<string, HashSet<string>>();
foreach (var taskId in taskIds)
{
var baseContent = await ReadFileContentAsync(repoPathByTask[taskId], baseRefByTask[taskId], file, ct);
var headContent = await ReadFileContentAsync(repoPathByTask[taskId], headRefByTask[taskId], file, ct);
if (!TryDiffJsonKeyPaths(baseContent, headContent, out var keys))
return null;
keysByTask[taskId] = keys;
}
var colliding = new HashSet<string>();
foreach (var a in taskIds)
foreach (var b in taskIds)
{
if (a == b) continue;
if (keysByTask[a].Overlaps(keysByTask[b]))
{
colliding.Add(a);
colliding.Add(b);
}
}
return taskIds.Where(colliding.Contains).ToList();
}
// gitRef null = read the live working tree off disk (mirrors GetBranchDiffAsync's own base-vs-
// working-tree comparison); non-null = `git show ref:path` (handler task's fixed commit range).
private async Task<string?> ReadFileContentAsync(string repoPath, string? gitRef, string relativePath, CancellationToken ct)
{
if (gitRef is null)
{
var full = Path.Combine(repoPath, relativePath);
return File.Exists(full) ? await File.ReadAllTextAsync(full, ct) : null;
}
return await _git.ShowFileAsync(repoPath, gitRef, relativePath, ct);
}
private static bool TryDiffJsonKeyPaths(string? baseContent, string? headContent, out HashSet<string> changedPaths)
{
changedPaths = new HashSet<string>(StringComparer.Ordinal);
if (!TryFlattenJson(baseContent, out var baseMap) || !TryFlattenJson(headContent, out var headMap))
return false;
foreach (var (path, value) in headMap)
if (!baseMap.TryGetValue(path, out var baseValue) || baseValue != value)
changedPaths.Add(path);
foreach (var path in baseMap.Keys)
if (!headMap.ContainsKey(path))
changedPaths.Add(path);
return true;
}
// Missing content (new/deleted file, either side) is an empty object, not a parse failure --
// only actually-malformed JSON triggers the file-level fallback.
private static bool TryFlattenJson(string? content, out Dictionary<string, string> flattened)
{
flattened = new Dictionary<string, string>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(content))
return true;
try
{
using var doc = JsonDocument.Parse(content);
FlattenJson(doc.RootElement, "", flattened);
return true;
}
catch (JsonException)
{
return false;
}
}
private static void FlattenJson(JsonElement element, string prefix, Dictionary<string, string> into)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var prop in element.EnumerateObject())
FlattenJson(prop.Value, prefix.Length == 0 ? prop.Name : $"{prefix}.{prop.Name}", into);
}
else
{
into[prefix] = element.GetRawText();
}
}
// One line of a unified diff, tagged with the file it belongs to ('-'/'+' only; context lines
// are dropped). File is set from the `diff --git a/<path> b/<path>` header, which (unlike
// `+++ b/...`) is present and names the real path even for a deleted file (`+++ /dev/null`).
private readonly record struct DiffLine(string? File, char Sign, string Text);
private static List<DiffLine> ParseDiffLines(string diff)
{
var result = new List<DiffLine>();
string? currentFile = null;
foreach (var raw in diff.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
{
var rest = line["diff --git a/".Length..];
var sep = rest.IndexOf(" b/", StringComparison.Ordinal);
currentFile = sep >= 0 ? rest[..sep] : rest;
continue;
}
if (currentFile is null) continue;
if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal) ||
line.StartsWith("@@", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal))
continue;
if (line.Length > 0 && line[0] == '-')
result.Add(new DiffLine(currentFile, '-', line[1..]));
else if (line.Length > 0 && line[0] == '+')
result.Add(new DiffLine(currentFile, '+', line[1..]));
}
return result;
}
// Deliberately a line-pattern scan, not a Roslyn parse: requires an explicit access modifier
// (this codebase's style never omits one on a real member/type), which is also what keeps a
// removed local variable or statement inside a method body from matching.
private static readonly Regex RemovedDeclarationRegex = new(
@"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected|internal)(?:\s+(?:static|readonly|sealed|abstract|virtual|override|async|partial|const|new|extern|unsafe|required))*\s+" +
@"(?:(?:class|struct|interface|enum|record)\s+(?<name>[A-Za-z_]\w*)" +
@"|event\s+[\w<>\[\],.?]+\s+(?<name>[A-Za-z_]\w*)" +
@"|[\w<>\[\],.?]+\??\s+(?<name>[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))",
RegexOptions.Compiled);
private static List<RemovedSymbolDto> ExtractRemovedSymbols(List<DiffLine> diffLines)
{
var seen = new HashSet<(string Name, string File)>();
var result = new List<RemovedSymbolDto>();
foreach (var line in diffLines)
{
if (line.Sign != '-' || line.File is null) continue;
if (line.File.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) ||
line.File.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
var match = RemovedDeclarationRegex.Match(line.Text);
if (!match.Success) continue;
if (seen.Add((match.Groups["name"].Value, line.File)))
result.Add(new RemovedSymbolDto(match.Groups["name"].Value, line.File));
}
return result;
}
private static bool ContainsWord(string text, string word)
=> Regex.IsMatch(text, $@"\b{Regex.Escape(word)}\b");
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
@@ -577,7 +841,7 @@ public sealed class MergeMcpTools
// (its commits already sit on the list's working dir) — falls back to the fixed
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree".
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
@@ -619,7 +883,8 @@ public sealed class MergeMcpTools
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList();
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff,
wt.Path, wt.BaseCommit, null);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
@@ -636,7 +901,8 @@ public sealed class MergeMcpTools
// No fork point to diff against: a handler task commits straight onto the list's
// working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork".
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff);
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff,
list.WorkingDir, handlerBase, handlerHead);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
+9 -6
View File
@@ -10,19 +10,22 @@ namespace ClaudeDo.Worker.External;
internal static class TaskBlocking
{
/// <summary>
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
/// edge as blocking too).
/// The predecessors of <paramref name="dependencyIds"/> the picker would still skip on: not
/// Done <i>and</i> without a landed merge. Mirrors <c>QueuePicker</c>'s SQL predicate — a
/// task merged with <c>skipVerify</c> sits in WaitingForReview until the batch verify runs,
/// yet its code is on the target branch, so it no longer blocks. An id missing from
/// <paramref name="predecessors"/> counts as unmet (a dangling edge blocks there too).
/// </summary>
public static List<(string Id, TaskStatus? Status)> Unmet(
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
IEnumerable<string> dependencyIds,
IReadOnlyDictionary<string, (TaskStatus Status, bool Merged)> predecessors)
{
var unmet = new List<(string Id, TaskStatus? Status)>();
foreach (var id in dependencyIds)
{
if (statuses.TryGetValue(id, out var status))
if (predecessors.TryGetValue(id, out var p))
{
if (status != TaskStatus.Done) unmet.Add((id, status));
if (p.Status != TaskStatus.Done && !p.Merged) unmet.Add((id, p.Status));
}
else
{
+11 -5
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
@@ -152,12 +153,17 @@ public sealed class TaskWaitMcpTools
.SelectMany(r => r.DependsOnTaskIds)
.Distinct()
.ToList();
var dependencyStatuses = dependencyIds.Count == 0
? new Dictionary<string, TaskStatus>()
var predecessors = dependencyIds.Count == 0
? new Dictionary<string, (TaskStatus Status, bool Merged)>()
: await ctx.Tasks.AsNoTracking()
.Where(t => dependencyIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
.Select(t => new
{
t.Id,
t.Status,
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
})
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
var result = new List<TaskStatusChangeDto>();
foreach (var id in taskIds)
@@ -175,7 +181,7 @@ public sealed class TaskWaitMcpTools
// every chained child and turn the wait into a one-turn-per-poll busy loop.
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
{
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, predecessors);
if (unmet.Count > 0)
{
result.Add(new TaskStatusChangeDto(
+4 -2
View File
@@ -449,7 +449,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
return rows.Select(r => new WorktreeOverviewDto(
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
r.TaskNumber)).ToList();
}
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
@@ -498,7 +499,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () =>
{
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
});
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -774,6 +775,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
_broadcaster.WorktreeUpdated(taskId);
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
});
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
@@ -20,17 +21,20 @@ public sealed class InteractiveReviewSubmissionService
private readonly GitService _git;
private readonly ITaskStateService _state;
private readonly WorktreeManager? _worktreeManager;
private readonly HubBroadcaster? _broadcaster;
public InteractiveReviewSubmissionService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ITaskStateService state,
WorktreeManager? worktreeManager = null)
WorktreeManager? worktreeManager = null,
HubBroadcaster? broadcaster = null)
{
_dbFactory = dbFactory;
_git = git;
_state = state;
_worktreeManager = worktreeManager;
_broadcaster = broadcaster;
}
public async Task SubmitAsync(string taskId, CancellationToken ct)
@@ -86,5 +90,14 @@ public sealed class InteractiveReviewSubmissionService
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
// The transition itself only broadcasts TaskUpdated, and the detail pane answers that by
// refreshing the task row alone -- its whole REVIEW/MERGE block (Open diff, branch picker,
// change summary) is gated on the worktree it last read, refreshed only on WorktreeUpdated.
// An interactive session creates its worktree at launch and gets it committed HERE, both
// outside the autonomous pipeline that would normally announce it, so without this the task
// arrives in WaitingForReview with the diff button missing until it is re-selected.
if (_broadcaster is not null)
await _broadcaster.WorktreeUpdated(taskId);
}
}
@@ -20,7 +20,8 @@ public sealed record MergeResult(
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage);
string DefaultCommitMessage,
bool TargetDirty = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -875,7 +876,8 @@ public sealed class TaskMergeService
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
}
/// The commit message a merge uses when the caller passes none. Built here rather than in the
+18 -6
View File
@@ -21,11 +21,19 @@ public sealed class QueuePicker : IQueuePicker
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
// Raw SQL because EF cannot express UPDATE...RETURNING.
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
// (or unscheduled). A task_dependencies edge only gates on the dependency's Status='done'
// -- unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
// with a Failed dependency simply stays here, skipped, until someone intervenes. The
// depends-on clause is doubly negated on purpose: "no edge whose predecessor isn't done"
// means a task with zero edges is eligible, ALL edges must be done, and an edge pointing
// (or unscheduled). A task_dependencies edge is satisfied once the predecessor is 'done'
// OR its merge has landed (worktrees.state = 'merged'), because review_task(skipVerify)
// deliberately parks a successfully merged task in WaitingForReview until the batch
// verify_merges runs -- gating on 'done' alone stalled every successor for the whole
// batch. Consequences, all deliberate: a failing verify_merges leaves the merges on the
// target branch, so successors do run (the predecessor's code is there either way);
// revert_merge/abort_merge flip the worktree back to 'active' and re-block; a rejected
// task (reject_rerun/reject_park) never reached 'merged' and stays unmet; a task with no
// worktree (handler task, empty diff) is only ever satisfied via 'done'. Unlike
// blocked_by_task_id there is no cascade on the dependency failing, so a task with a
// Failed dependency simply stays here, skipped, until someone intervenes. The depends-on
// clause is doubly negated on purpose: "no edge whose predecessor isn't satisfied" means
// a task with zero edges is eligible, ALL edges must be satisfied, and an edge pointing
// at a vanished task keeps blocking (same as the old single-column form did).
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
var rows = await ctx.Tasks.FromSqlRaw("""
@@ -41,7 +49,11 @@ public sealed class QueuePicker : IQueuePicker
WHERE td.task_id = t.id
AND NOT EXISTS (
SELECT 1 FROM tasks d
WHERE d.id = td.depends_on_task_id AND d.status = 'done'
WHERE d.id = td.depends_on_task_id
AND (d.status = 'done' OR EXISTS (
SELECT 1 FROM worktrees w
WHERE w.task_id = d.id AND w.state = 'merged'
))
)
)
ORDER BY t.sort_order ASC, t.created_at ASC
@@ -44,7 +44,7 @@ public class IslandsShellViewModelShutdownTests
private sealed class FakeDialogService(bool confirmResult) : IDialogService
{
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(confirmResult);
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
@@ -0,0 +1,52 @@
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests.Services;
public class ClipboardImageTempStoreTests : IDisposable
{
private readonly string _dir;
public ClipboardImageTempStoreTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose() { try { Directory.Delete(_dir, true); } catch { } }
[Fact]
public void NewPngPath_IsUniqueAndUnderTheGivenDir()
{
var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc);
var a = ClipboardImageTempStore.NewPngPath(_dir, now);
var b = ClipboardImageTempStore.NewPngPath(_dir, now);
Assert.NotEqual(a, b);
Assert.Equal(_dir, Path.GetDirectoryName(a));
Assert.Equal(".png", Path.GetExtension(a));
}
[Fact]
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
{
var now = DateTime.UtcNow;
var oldFile = Path.Combine(_dir, "old.png");
var freshFile = Path.Combine(_dir, "fresh.png");
File.WriteAllText(oldFile, "x");
File.WriteAllText(freshFile, "x");
File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25));
File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1));
ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now);
Assert.False(File.Exists(oldFile));
Assert.True(File.Exists(freshFile));
}
[Fact]
public void DeleteOlderThan_NonExistentDir_DoesNotThrow()
{
ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow);
}
}
+43
View File
@@ -1,6 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests;
@@ -31,3 +34,43 @@ public sealed class StubNotesApi : INotesApi
/// <summary><see cref="StubWorkerClient"/> is abstract — this is the concrete no-op subclass.</summary>
public sealed class DefaultStub : StubWorkerClient { }
/// <summary>
/// Records the message/label of the last Confirm/Error call and returns a configurable
/// Confirm result. Covers the ViewModels (DetailsIsland, TasksIsland, ...) that take their
/// dialog seam via a settable <c>Dialogs</c> property instead of a per-call constructor arg.
/// </summary>
public sealed class RecordingDialogService : IDialogService
{
public bool ConfirmResult { get; set; } = true;
public string? LastConfirmMessage { get; private set; }
public string? LastConfirmLabel { get; private set; }
public string? LastErrorMessage { get; private set; }
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
LastConfirmMessage = message;
LastConfirmLabel = confirmLabel;
return Task.FromResult(ConfirmResult);
}
public Task ShowErrorAsync(string message)
{
LastErrorMessage = message;
return Task.CompletedTask;
}
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
}
@@ -90,12 +90,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
var closed = false;
vm.CloseDetail = () => closed = true;
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.DeleteTaskCommand.ExecuteAsync(null);
Assert.Equal(fkMessage, reportedError);
Assert.Equal(fkMessage, dialogs.LastErrorMessage);
Assert.False(deleteFromListCalled);
Assert.False(closed);
}
@@ -136,12 +136,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
var closed = false;
vm.CloseDetail = () => closed = true;
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.DeleteTaskCommand.ExecuteAsync(null);
Assert.Equal("Connection is not active.", reportedError);
Assert.Equal("Connection is not active.", dialogs.LastErrorMessage);
Assert.False(deleteFromListCalled);
Assert.False(closed);
}
@@ -164,7 +164,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed });
vm.Monitor.ApplyState(TaskStatus.Failed);
vm.ConfirmAsync = _ => Task.FromResult(true);
vm.Dialogs = new RecordingDialogService { ConfirmResult = true };
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
@@ -153,12 +153,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
Assert.Equal(worker.ExceptionMessage, dialogs.LastErrorMessage);
Assert.False(vm.ApproveOp.IsRunning);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
}
@@ -62,7 +62,7 @@ public class ListsIslandCreateListCancelTests : IDisposable
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public Task<bool> ConfirmAsync(string message) => Task.FromResult(true);
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(true);
public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
@@ -263,7 +263,8 @@ public class ListsIslandFolderTests : IDisposable
var folder = vm.Folders[0];
Assert.False(folder.IsCollapsed);
await vm.ToggleFolderCommand.ExecuteAsync(folder);
vm.ToggleFolderCommand.Execute(folder);
await Task.Delay(100);
Assert.True(folder.IsCollapsed);
await using var db = NewContext();
@@ -275,6 +276,31 @@ public class ListsIslandFolderTests : IDisposable
Assert.True(reloaded.Folders[0].IsCollapsed);
}
[Fact]
public async Task ToggleFolder_rapid_double_click_both_toggles_land()
{
// Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async
// [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click
// while the first one's DB write was still in flight. Firing two toggles back-to-back
// (no await between them) reproduces exactly that window.
await SeedAsync("f1", ("inside", "Inside", 0));
var vm = NewVm();
await vm.LoadAsync();
var folder = vm.Folders[0];
Assert.False(folder.IsCollapsed);
vm.ToggleFolderCommand.Execute(folder);
vm.ToggleFolderCommand.Execute(folder);
await Task.Delay(100);
// Two flips land back on the original (visible) state...
Assert.False(folder.IsCollapsed);
// ...and the DB ends up agreeing with it, not with the stale mid-flight value.
await using var db = NewContext();
Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
}
[Fact]
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
{
@@ -69,6 +69,30 @@ public class MergeModalViewModelTests
Assert.NotEqual("", vm.CommitMessage);
}
[Fact]
public async Task Initialize_flags_a_dirty_target_up_front()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: true);
await vm.InitializeAsync("task-1", "do the thing");
Assert.True(vm.ShowStashRetry);
Assert.False(string.IsNullOrWhiteSpace(vm.WarningMessage));
}
[Fact]
public async Task Initialize_does_not_flag_a_clean_target()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: false);
await vm.InitializeAsync("task-1", "do the thing");
Assert.False(vm.ShowStashRetry);
Assert.Null(vm.WarningMessage);
}
[Fact]
public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after()
{
@@ -43,7 +43,7 @@ public class TasksIslandMoveToListTests : IDisposable
public int ConfirmCalls;
public string? LastMessage;
public Task<bool> ConfirmAsync(string message)
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
ConfirmCalls++;
LastMessage = message;
@@ -114,6 +114,32 @@ public class WorktreesOverviewReconcileTickTests
Assert.Equal(1, vm.SelectedCount);
}
[Fact]
public async Task Map_carries_the_task_number_and_hides_the_badge_when_zero()
{
var worker = new FakeWorker
{
Worktrees =
{
Wt("a") with { TaskNumber = 42 },
Wt("b") with { TaskNumber = 0 },
},
};
var vm = new WorktreesOverviewModalViewModel(
worker, () => throw new InvalidOperationException("no diff vm in this test"),
new ClaudeDo.Ui.Services.MergeCoordinator());
vm.Configure("L1", "Work");
await vm.LoadAsync();
var withNumber = vm.Rows.Single(r => r.TaskId == "a");
var withoutNumber = vm.Rows.Single(r => r.TaskId == "b");
Assert.Equal(42, withNumber.TaskNumber);
Assert.True(withNumber.ShowNumberBadge);
Assert.Equal(0, withoutNumber.TaskNumber);
Assert.False(withoutNumber.ShowNumberBadge);
}
[Theory]
[InlineData("refresh")]
[InlineData("cleanup")]
@@ -472,6 +472,34 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Null(dto.BlockedReason);
}
[Fact]
public async Task GetTask_QueuedWithMergedButUnverifiedDependsOn_ReportsBlockedFalse()
{
// Same rule as QueuePicker: a predecessor merged with skipVerify sits in
// WaitingForReview until the batch verify, but the picker already claims the successor --
// get_task must not contradict that by still calling it blocked.
var listId = await SeedListAsync();
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = predecessor.Id,
Path = $"/tmp/{predecessor.Id}",
BranchName = $"claudedo/{predecessor.Id}",
BaseCommit = "abc123",
State = WorktreeState.Merged,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.False(dto.Blocked);
Assert.Null(dto.BlockedReason);
}
[Fact]
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
{
+177 -14
View File
@@ -163,12 +163,13 @@ public sealed class MergeMcpToolsTests : IDisposable
}
private async Task<string> CreateActiveWorktreeAsync(
GitRepoFixture repo, string taskId, string fileName, string content)
GitRepoFixture repo, string taskId, string fileName, string content, string? baseCommit = null)
{
baseCommit ??= repo.BaseCommit;
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
File.WriteAllText(Path.Combine(wtPath, fileName), content);
GitRepoFixture.RunGit(wtPath, "add", fileName);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
@@ -177,7 +178,7 @@ public sealed class MergeMcpToolsTests : IDisposable
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
BaseCommit = baseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
@@ -793,7 +794,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Empty(result.ConflictFiles);
@@ -815,7 +816,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
@@ -843,7 +844,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
@@ -875,7 +876,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(1, result.ChangedFileCount);
@@ -893,7 +894,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
@@ -910,7 +911,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
}
[Fact]
@@ -928,7 +929,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
}
@@ -952,7 +953,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, runVerify: true, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.VerifyExitCode);
@@ -962,6 +963,34 @@ public sealed class MergeMcpToolsTests : IDisposable
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
var git = new GitService();
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Null(result.VerifyExitCode);
Assert.Null(result.VerifyDurationMs);
Assert.Null(result.VerifyOutputTail);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
{
@@ -982,7 +1011,7 @@ public sealed class MergeMcpToolsTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
@@ -1010,7 +1039,7 @@ public sealed class MergeMcpToolsTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
@@ -1042,7 +1071,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.NotNull(result.StaleFiles);
Assert.Empty(result.StaleFiles!);
@@ -1289,4 +1318,138 @@ public sealed class MergeMcpToolsTests : IDisposable
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMergeSet_RemovedSymbolReferencedByAnotherTask_ReportsSymbolWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "Foo.cs"),
"public class Foo\n{\n private void RestartWorkerStatus()\n {\n }\n}\n");
File.WriteAllText(Path.Combine(repo.RepoDir, "Bar.cs"), "public class Bar\n{\n}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add Foo/Bar");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var removerTask = await SeedTaskAsync(listId, "removes", TaskStatus.WaitingForReview);
var referencerTask = await SeedTaskAsync(listId, "references", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, removerTask.Id, "Foo.cs", "public class Foo\n{\n}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, referencerTask.Id, "Bar.cs",
"public class Bar\n{\n private void Call()\n {\n RestartWorkerStatus();\n }\n}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet(
[removerTask.Id, referencerTask.Id], "main", cancellationToken: CancellationToken.None);
var removerEntry = result.Tasks.Single(t => t.TaskId == removerTask.Id);
Assert.Contains(removerEntry.RemovedSymbols!, s => s.Name == "RestartWorkerStatus" && s.File == "Foo.cs");
var warning = Assert.Single(result.SymbolWarnings);
Assert.Equal("RestartWorkerStatus", warning.Symbol);
Assert.Equal("Foo.cs", warning.RemovedInFile);
Assert.Equal(removerTask.Id, warning.RemovedByTaskId);
Assert.Equal(referencerTask.Id, warning.ReferencedByTaskId);
Assert.Equal("Bar.cs", warning.ReferencedInFile);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksAddDifferentJsonKeys_ReportsNoOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"two\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
Assert.Empty(result.Overlaps);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksChangeSameJsonKey_ReportsOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
[Fact]
public async Task PreviewMergeSet_UnparsableJsonOnOneSide_FallsBackToFileOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
// taskA leaves the file syntactically broken -- both sides must still be flagged since
// there's no way to tell whether the keys actually collide.
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{ not valid json", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
}
@@ -138,6 +138,74 @@ public sealed class QueuePickerTests : IDisposable
Assert.Equal(dependent.Id, picked!.Id);
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
{
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId,
Path = $"/tmp/{taskId}",
BranchName = $"claudedo/{taskId}",
BaseCommit = "abc123",
State = state,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsMergedButAwaitingBatchVerify()
{
// review_task(skipVerify: true) leaves a successfully merged task in WaitingForReview
// until verify_merges runs once for the whole batch. Gating on Done alone stalled every
// successor for the entire batch, so a landed merge satisfies the edge.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Merged);
var dependent = await SeedAsync(listId, dependsOn: [dependency.Id]);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(dependent.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnWasRejected()
{
// reject_rerun/reject_park never merge, so the worktree stays Active -- the successor
// must keep waiting even though the predecessor left WaitingForReview.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.Idle);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnMergeWasReverted()
{
// revert_merge / abort_merge put the worktree back to Active -- the edge is unmet again.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseWorktreelessDependsOnIsNotDone()
{
// A handler task or an empty-diff run has no worktrees row at all: only 'done' can
// satisfy that edge, and the merged-branch must not accidentally match a missing row.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers()
{
@@ -374,6 +374,38 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
}
[Fact]
public async Task GetTargetsAsync_UncommittedChangeInTarget_ReportsTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.True(targets.TargetDirty);
}
[Fact]
public async Task GetTargetsAsync_OnlyUntrackedFileInTarget_DoesNotReportTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.False(targets.TargetDirty);
}
[Fact]
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
{