# UI-Reaktivität und Listen-Performance

**Datum:** 2026-08-07
**Status:** Design freigegeben, Implementierung offen

## Problem

Zwei Symptome, die als eines gemeldet wurden:

1. **Stale UI.** Ein Task bleibt in der Liste auf `Queued` stehen, obwohl der Worker ihn längst auf `Running` gesetzt hat. Ebenso tauchen extern angelegte Tasks (Online-Inbox, List-Handler) erst nach einem manuellen Neuladen auf. Modals und Overlays zeigen den Stand vom Öffnungszeitpunkt.
2. **Langsames Laden.** Eine Liste mit ~125 erledigten Tasks braucht 1–2 Sekunden zum Öffnen. Ziel sind Listen mit bis zu ~1000 erledigten Tasks.

## Analyse

### Reaktivität: es fehlt kein Event, es fehlt die Selbstheilung

Der Broadcast-Pfad ist im Grundsatz korrekt: der Worker schreibt in die DB, committet, und sendet danach eine ID über SignalR (`HubBroadcaster`); die UI lädt die Entity frisch nach. WAL-Sichtbarkeit und EF-Change-Tracking wurden als Ursache **ausgeschlossen** — die UI nutzt `IDbContextFactory` mit kurzlebigen Kontexten, und WAL-Reader sehen Commits sofort.

Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der einzige Reconcile-Trigger ist heute `ConnectionRestoredEvent`, also ein Verbindungsabbruch. Es gibt drei Wege, auf denen ein Update verloren geht:

| # | Loch | Ort |
|---|---|---|
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` |

**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen.

Dazu zwei kleinere Befunde:

- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout` → `30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout.
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.

### Performance: der Engpass ist das Rendering, nicht die Datenbank

SQLite ist hier **nicht** der Engpass, und ein DB-Wechsel würde nichts verbessern. Die Kosten verteilen sich so:

| Posten | bei 125 Zeilen |
|---|---|
| SQLite-Read, 125 Zeilen × ~30 Spalten, 2 Joins | < 1 ms |
| EF-Materialisierung | ~1–5 ms |
| **Avalonia baut ~8.500 Controls mit Bindings** | **~1.000–1.500 ms** |

Eine `TaskRowView` erzeugt **~68 Controls eager**: ~50 für Struktur und Inhalt plus 18 `MenuItem`-Deklarationen des inline deklarierten ContextMenus (`TaskRowView.axaml:35-85`). Die Liste ist **nicht virtualisiert**: drei `ItemsControl` (Overdue/Open/Completed) liegen in einem gemeinsamen `ScrollViewer` ohne `ItemsPanel`-Override (`TasksIslandView.axaml:100,119,152`). `ItemsControl` nutzt per Default ein normales `StackPanel`, und der gemeinsame `ScrollViewer` gibt allen dreien unbegrenzte Höhe — deshalb würde auch ein bloßes Setzen von `VirtualizingStackPanel` nichts bewirken.

Zeilenhöhe ~68px, verfügbare Listenhöhe auf 2560×1440 ~1250px → **~19 Zeilen gleichzeitig sichtbar**.

| | Controls | geschätzt |
|---|---|---|
| heute, 125 Tasks | ~8.500 | 1–2 s |
| heute, 1000 Tasks | ~68.000 | ~10 s+ |
| virtualisiert (19 sichtbar + Overscan ≈ 25 Zeilen) | ~1.700 | ~250 ms |
| + ContextMenu lazy | ~1.250 | ~180 ms |

Entscheidend: die virtualisierten Werte sind **konstant** und gelten für 125 wie für 10.000 Zeilen.

Nebenbefund: `LoadForList` (`TasksIslandViewModel.cs:305-309`) hat **kein `.Where()` vor `ToListAsync()`** — es lädt die komplette `tasks`-Tabelle aller Listen mit zwei Joins und filtert danach in C#. Die vorhandenen Indizes (`idx_tasks_list_id`, `idx_tasks_status`) werden dadurch nie genutzt. Bei der heutigen DB-Größe unkritisch, aber es skaliert mit der DB-Gesamtgröße statt mit der Listengröße.

### Drag & Drop: architektonisch virtualisierungsfähig

Die Task-Liste nutzt **kein** Avalonia-`DragDrop` pro Item, sondern ein eigenes Ghost-Drag:

- Vier Pointer-Handler hängen **zentral** an der `TasksIslandView` (`TasksIslandView.axaml.cs:40-43`, Tunnel-Routing) — keine pro-Zeile registrierten Handler, die beim Container-Recycling leaken könnten.
- Das Ziel wird per `InputHitTest` live ermittelt (`TasksIslandView.axaml.cs:304-329`) — kein Index-Hack, recycling-sicher.
- Drop-Hints sind reine ViewModel-Properties (`TaskRowViewModel.cs:25-26`, `DropHintAbove`/`DropHintBelow`).
- Der Ghost ist ein `RenderTargetBitmap`-Snapshot in einem separaten Topmost-Fenster (`Views/Controls/TaskDragController.cs`), losgelöst vom Visual Tree.

Anzupassen sind:

- `FindNextInSameSection` und `SectionFor` (`TasksIslandViewModel.cs:366-374`, `:622-628`) iterieren per `IndexOf` über die drei UI-Collections. Die flache Master-Collection `Items` existiert bereits (`TasksIslandViewModel.cs:55`) und ist korrekt sortiert — das ist der Grund, warum das Flatten bezahlbar ist.
- **Auto-Scroll beim Ziehen fehlt komplett.** Fällt heute weniger auf, weil alle Container realisiert sind; bei einer virtualisierten Liste ist es Pflicht.
- **Bestehender Bug:** Zieht man über eine Gruppengrenze, prüft `ReorderAsync` (`TasksIslandViewModel.cs:560-563`) zwar die Sektion, verschiebt bei ungleichen Sektionen aber nur `Items` ohne anschließendes `Regroup()`. `SortOrder` landet in der DB, die UI zeigt nichts.
- **Kein Präzedenzfall:** im gesamten `ClaudeDo.Ui`-Projekt existiert kein `VirtualizingStackPanel` und kein `ItemsRepeater`.

### Drag-Optik

Heute laufen zwei Darstellungen derselben Zeile gleichzeitig: der Bitmap-Ghost am Cursor **und** die Originalzeile mit `Opacity 0.55`, `scale(1.03)`, BoxShadow und Accent-Rand (`IslandStyles.axaml:441-446`). `scale(1.03)` ändert kein Layout und überlappt daher die Nachbarzeilen. Das Feedback für das Drop-Ziel ist der ganz normale `:pointerover`-Hover (`IslandStyles.axaml:433-435`, setzt nur `BorderBrush`) — dasselbe Signal wie beim harmlosen Drüberfahren; einen eigenen `drop-target`-Style gibt es nur für die Lists-Island (`Border.list-item.drop-target`). Zusätzlich laufen `BrushTransition` (0.12s) und `ThicknessTransition` auf `Margin` (0.15s) auch während des Drags mit, was die Rückmeldung verschwimmen lässt.

## Verworfene Alternativen

| Alternative | Warum verworfen |
|---|---|
| **SQLite ersetzen** | Kein Engpass. Der DB-Read liegt unter 1 ms; die Zeit steckt zu >95% im Aufbau des Visual Tree. Monatelange Arbeit für null messbaren Gewinn. |
| **Nur Task-Titel laden, Rest lazy beim Öffnen** | Richtige Intuition, falsche Ebene. Spart Bytes aus einer lokalen Datei, die in unter 1 ms gelesen wird. Die 125 Zeilen werden weiterhin als 125 vollständige `TaskRowView` gebaut. Um wirklich zu sparen, müsste das Zeilen-Template entkernt werden — also genau die Chips und Icons entfallen, wegen derer die Liste nützlich ist. Als *Zusatz* (schlanke Projektion für Speicher/Materialisierung) sinnvoll, als Hauptmaßnahme nicht. |
| **Completed einklappen + „mehr laden"** | Billig und sofort wirksam, aber aufgeklappt mit 1000 Zeilen hängt es wieder. Bleibt als **Fallback**, falls der Virtualisierungs-Spike scheitert. |
| **Completed archivieren** | Löst das Problem durch Vermeidung; der Wunsch war ausdrücklich, 1000 erledigte Tasks sehen zu können. |
| **`Revision`-Spalte / Change-Feed** | Strukturell sauber (verlorene Events wären egal, Race gelöst), aber Migration plus Anpassung jedes Schreibpfads. Für eine Single-User-Desktop-App mit lokaler DB Overkill; der Reconcile-Tick erreicht dasselbe Ziel deutlich billiger. Bleibt als Eskalation, falls Phase 3 in der Praxis nicht reicht. |
| **Nur die Löcher stopfen (ohne Reconcile)** | Behebt die bekannten Fälle, lässt die Architektur „ein verlorener Event = permanent stale" aber intakt. Das nächste Loch kommt mit dem nächsten Feature. |

## Design

### Phase 1 — Reaktivitäts-Löcher schließen

Unabhängig von Phase 2 und 3, kann sofort starten.

| Fix | Ort |
|---|---|
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |

Der ungeschützte Setup-Block in `TaskRunner.ContinueAsync` (Zeilen 218–238) wird **nicht** separat umgebaut: sobald `RunInSlotAsync` im Fehlerfall `FailAsync` ruft, ist jede dort geworfene Exception abgedeckt — der Task landet auf `Failed` und der Broadcast erfolgt. Ein zweiter Schutzwall wäre doppelt.

### Phase 2 — Flache virtualisierte Liste

**Datenmodell.** `Regroup()` erzeugt statt drei Collections **eine** `Rows`-Collection vom Union-Typ (`HeaderRow` | `TaskRowViewModel`), abgeleitet aus der bereits vorhandenen flachen `Items`. Gruppenüberschriften werden zu regulären Einträgen:

```
Rows
 [0]  HeaderRow   "Überfällig (3)"
 [1]  TaskRow     …
 [4]  HeaderRow   "Offen (12)"
 …
 [17] HeaderRow   "Erledigt (125)"
 …
```

**View.** Eine `ListBox` mit `VirtualizingStackPanel` und einem DataTemplate-Selector (Header / Task) ersetzt die drei `ItemsControl` und den umschließenden `ScrollViewer`.

**Drag.** `FindNextInSameSection`, `SectionFor` und `ReorderAsync` rechnen gegen `Items` statt gegen die UI-Collections. Auto-Scroll beim Ziehen an den Listenrand wird neu gebaut. Der Cross-Section-Reorder-Bug wird im selben Zug behoben, da die Sektionsgrenzen in der flachen Struktur ohnehin explizit modelliert werden müssen.

**Zeilenkosten.** Das ContextMenu wird bei `ContextRequested` im Code-Behind aufgebaut statt als 18 `MenuItem`s pro Template-Instanz.

**Query.** `.Where()` wandert vor `ToListAsync()`, damit die Query mit der Listengröße statt der DB-Gesamtgröße skaliert. Erfordert Umbau von `ITaskListFilter` von In-Memory-Prädikaten (`Matches(TaskEntity)`) auf `IQueryable`-Expressions.

**Drag-Optik (Variante A).** Ghost folgt dem Cursor; die Originalzeile kollabiert zu einer leeren, gestrichelten Lücke, die beim Ziehen an die jeweilige Zielposition mitwandert.

```
   ┌──────────────────────┐
   │ Fix login bug        │
   ├──────────────────────┤
   │ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │ ← leerer Slot, wandert mit
   ├──────────────────────┤
   │ Rebase branch        │
   └──────────────────────┘
        ┌────────────────┐
        │ Update deps    │ ← Ghost am Cursor
        └────────────────┘
```

Begleitend: `scale(1.03)` und BoxShadow auf der gezogenen Zeile entfallen; der normale `:pointerover`-Hover wird während eines aktiven Drags per `dragging`-Klasse am Container unterdrückt; Transitions sind während des Drags aus.

### Phase 3 — Reconcile-Tick

Setzt Phase 2 voraus: auf einer Liste, die 1–2 s zum Laden braucht, würde ein periodischer Abgleich alles verschlimmern.

Ein Timer (3–5 s) gleicht die **sichtbaren** Rows gegen die lokale SQLite ab und **patcht ausschließlich Properties** — er baut nie Zeilen neu und löst nie einen `LoadForList` aus. Bei einer lokalen DB und wenigen Dutzend sichtbaren Zeilen ist das ein einzelner indizierter Query.

Damit ist jeder verlorene Event nach spätestens einem Tick geheilt, unabhängig davon, wo er fehlte. Derselbe Tick versorgt zusätzlich die langlebigen Overlays: Worktrees-Overview, LogVisualizer und die MergeHelper-Auswahl.

Kurzlebige Modals (Settings, ListSettings, RepoImport, WeeklyReport, ConflictResolver) bleiben bewusst statisch — ein Dialog, der sich unter den Fingern des Nutzers ändert, ist schlechter als einer, der den Stand vom Öffnen zeigt.

## Tests

- **Worker.Tests:** Broadcast-Assertions über einen Fake-Broadcaster auf allen in Phase 1 gefixten Pfaden — insbesondere, dass der Fehlerfall in `RunInSlotAsync` einen Broadcast auslöst.
- **Ui.Tests:** Der Reconcile-Tick patcht abweichende Properties; ein Ergebnis mit veralteter Sequenznummer wird verworfen; `Regroup()` erzeugt die korrekte `Rows`-Folge inklusive Header-Positionen und -Zählern.
- **Nicht automatisiert testbar:** Ladezeit und Drag-Optik. Beides erfordert eine manuelle Gegenmessung mit einer großen Liste durch den Nutzer.

Keine Tests, die die echte `claude`-CLI starten.

## Risiken

1. **Kein Virtualisierungs-Präzedenzfall im Projekt.** Das HitTest-basierte Custom-Drag ist theoretisch tragfähig, aber nie gegen recycelte Container verifiziert. **Die erste Aufgabe in Phase 2 ist ein Spike**, kein Umbau: eine virtualisierte Liste mit dem bestehenden Drag, inklusive Recycling während eines aktiven Drags und Auto-Scroll. Scheitert der Spike, ist der Fallback „Completed einklappen + nachladen".
2. **Variable Zeilenhöhen.** 68 px im Normalfall, 90–110 px bei zweizeiligem Titel oder mehreren Badges. `VirtualizingStackPanel` beherrscht das, aber die Scrollbar springt dabei gern, weil die Gesamthöhe geschätzt wird. Muss im Spike mitgeprüft werden.
3. **`ITaskListFilter`-Umbau.** Die Umstellung auf `IQueryable`-Expressions berührt die Filter-Registry (`src/ClaudeDo.Data/Filtering/`) und damit auch die virtuellen Listen. Kann bei Bedarf aus Phase 2 herausgelöst und nachgezogen werden — der Performance-Gewinn liegt heute ohnehin fast vollständig beim Rendering.

## Reihenfolge

Phase 1 → Phase 2 (Spike zuerst) → Phase 3. Phase 1 ist unabhängig und kann parallel oder vorab laufen.
