diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DiffDispatcherGuardTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffDispatcherGuardTests.cs
new file mode 100644
index 00000000..81673cad
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DiffDispatcherGuardTests.cs
@@ -0,0 +1,167 @@
+using System.Collections.Concurrent;
+using System.IO;
+using System.Text;
+using System.Threading;
+using ClaudeDo.Ui.Services;
+using ClaudeDo.Ui.ViewModels.Modals;
+using Xunit;
+
+namespace ClaudeDo.Ui.Tests.ViewModels;
+
+///
+/// [B3] The one blockade-regression test in the operation-feedback effort. Proves that a large
+/// diff no longer runs its parse work inline on the calling ("UI") thread — [B1]'s
+/// offload via .
+///
+///
+///
+/// Option B chosen over Option A (see plan, Gruppe B / B3). This test project has no
+/// bootstrapped Avalonia dispatcher anywhere — Avalonia.Headless.XUnit / [AvaloniaFact]
+/// is not referenced, and every existing test that would otherwise need one (e.g.
+/// Services/OperationStatusTests.cs) sidesteps it via a synchronous seam instead.
+///
+///
+/// Instead, a custom is installed as
+/// on the calling thread before the property write
+/// that triggers the parse. [B1] routes its offloaded work through await Task.Run(...),
+/// and the C# compiler only defers an await's continuation to the ambient
+/// when the awaited task is not yet complete at the
+/// await point — which Task.Run guarantees (its delegate never runs inline on the calling
+/// thread). So immediately after the triggering property set returns, the parsed result must
+/// still be the previous (empty) one — not because of a timing race that could flip either way,
+/// but because the continuation that would write it is sitting, unpumped, in the queue. Draining
+/// that queue () is what a real dispatcher's message
+/// loop would do; this proves the calling thread stayed free to keep pumping while the work ran
+/// elsewhere, which is the actual regression [B1] guards against. Unlike a bare
+/// ManagedThreadId check, this also fails for the right reason if someone reintroduces a
+/// .Result/.Wait() block that defeats the offload without technically running on the
+/// calling thread's own stack frame.
+///
+///
+/// [B2]'s offload (in )
+/// is deliberately NOT covered here. That offload only runs inside a real
+/// control, and constructing one at all —
+/// even off-screen, never attached — requires a bootstrapped Avalonia Application (its
+/// AXAML resolves StaticResource/loc:Tr bindings during InitializeComponent).
+/// Three escalating attempts to make that safe in-process were each falsified empirically against
+/// the FULL ClaudeDo.Ui.Tests suite (passing in isolation is not enough — see the project's
+/// own "order-dependent flakiness" lesson): (1) a lazily-bootstrapped headless
+/// AppBuilder...UseHeadless()...SetupWithoutStarting() call crashed with
+/// Dispatcher.VerifyAccess() ("a different thread owns it") the first time any test
+/// in the 500+ suite tried it, because Avalonia's Dispatcher.UIThread binds to whichever
+/// thread touches it first and xUnit's worker pool gives no thread guarantee; (2) forcing that
+/// bootstrap to run via [ModuleInitializer] (so it wins the race) moved the same failure
+/// one level deeper — routine AvaloniaObject.SetValue property-change notifications on the
+/// control itself also assert dispatcher-thread affinity, and the two [Fact]s in this class
+/// still landed on different xUnit worker threads; (3) marshalling the whole test body onto one
+/// dedicated background thread (a hand-rolled stand-in for what Avalonia.Headless.XUnit's
+/// custom test framework does) fixed the affinity crash, but bootstrapping a real, permanent,
+/// process-wide Application.Current as a side effect then broke an unrelated, previously
+/// green test (WorkerLogLevelToBrushConverterTests's "no app" fallback case) purely by
+/// having run earlier in the same test host process. Adding Avalonia.Headless.XUnit itself
+/// (the mechanism that actually solves this, by owning test scheduling) was tried too: it pulls in
+/// xunit.v3.*, which collides with this project's xunit 2.9.3 — FactAttribute/
+/// TheoryAttribute/InlineDataAttribute became ambiguous across ~30 existing test
+/// files (CS0433), i.e. it would require migrating the whole test project to xUnit v3, far outside
+/// a guard test's scope. [B2] is left to the plan's existing "visuelle Prüfung offen" item; a
+/// follow-up to actually cover it would need either that migration or a purpose-built, isolated
+/// (e.g. separate test assembly/process) Avalonia UI test harness.
+///
+///
+public class DiffDispatcherGuardTests
+{
+ // 2 000 changed line-pairs (4 000 raw diff lines) — long enough that a synchronous
+ // UnifiedDiffParser.Parse would be a visibly dropped frame (measured ~15ms on dev hardware for
+ // parsing alone), short enough that this test stays well under a second even without offload.
+ private const int LinePairCount = 2000;
+
+ private static string BuildLargeDiff(int pairCount)
+ {
+ var sb = new StringBuilder();
+ sb.Append("diff --git a/big.cs b/big.cs\n--- a/big.cs\n+++ b/big.cs\n");
+ sb.Append($"@@ -1,{pairCount} +1,{pairCount} @@\n");
+ for (var i = 0; i < pairCount; i++)
+ {
+ sb.Append($"- var value{i} = ComputeSomething(alpha{i}, beta{i}, gamma{i});\n");
+ sb.Append($"+ var value{i} = ComputeSomethingElse(alpha{i}, beta{i}, gamma{i});\n");
+ }
+ return sb.ToString();
+ }
+
+ /// Captures every ed continuation instead of running it, so a test can
+ /// assert on state before it decides to drain the queue. is intentionally
+ /// left unimplemented (base throws) — nothing under test uses synchronous marshalling.
+ private sealed class QueueingSyncContext : SynchronizationContext
+ {
+ private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _queue = new();
+
+ public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state));
+
+ public bool RunPending()
+ {
+ var ran = false;
+ while (_queue.TryDequeue(out var item))
+ {
+ item.Callback(item.State);
+ ran = true;
+ }
+ return ran;
+ }
+ }
+
+ private sealed class GuardWorkerClient : StubWorkerClient
+ {
+ public IReadOnlyList AggregateResult { get; set; } = Array.Empty();
+ public override Task> GetPlanningAggregateAsync(string planningTaskId) =>
+ Task.FromResult(AggregateResult);
+ }
+
+ [Fact]
+ public async Task PlanningParse_OfLargeDiff_DoesNotRunOnCallingThread()
+ {
+ var ctx = new QueueingSyncContext();
+ var previous = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(ctx);
+ try
+ {
+ var raw = BuildLargeDiff(LinePairCount);
+ var fake = new GuardWorkerClient
+ {
+ AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, raw) },
+ };
+ var vm = new DiffViewerViewModel(null!, fake, new AppSettings
+ {
+ ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"),
+ });
+ vm.ConfigurePlanning("plan-1", "main");
+
+ // LoadAsync's own await chain only ever awaits already-completed tasks
+ // (Task.FromResult, then synchronous property setters), so it never suspends and
+ // never touches the sync context itself — only the fire-and-forget parse it
+ // triggers as a side effect does. Auto-selecting the first (only) subtask sets
+ // DisplayedDiff to the large diff, which kicks off that parse.
+ await vm.LoadAsync();
+
+ // Deterministic, not a race: the continuation that would populate PlanningFiles is
+ // parked in `ctx`, unpumped. This is exactly what must be false pre-[B1], where
+ // OnDisplayedDiffChanged parsed and wrote PlanningFiles synchronously and inline.
+ Assert.Empty(vm.PlanningFiles);
+
+ var pumped = false;
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (DateTime.UtcNow < deadline && !pumped)
+ {
+ pumped = ctx.RunPending();
+ if (!pumped) Thread.Sleep(5);
+ }
+
+ Assert.True(pumped, "the offloaded parse never posted its completion back");
+ var file = Assert.Single(vm.PlanningFiles);
+ Assert.Equal("big.cs", file.Path);
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+ }
+}