docs(specs): design for UI reactivity and task-list performance

This commit is contained in:
mika kuns
2026-08-07 08:49:33 +02:00
parent 315bea7cf9
commit 482d7ec332
@@ -0,0 +1,172 @@
# 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 12 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 218238 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`, `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:447` |
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.
- **`busy_timeout` ist nicht gesetzt** (`src/ClaudeDo.Data/ClaudeDoDbContext.cs:93`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1.
- **`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 | ~15 ms |
| **Avalonia baut ~8.500 Controls mit Bindings** | **~1.0001.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 | 12 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` |
| `PRAGMA busy_timeout` setzen (gilt für beide Prozesse, da in `MigrateAndConfigure`) | `ClaudeDoDbContext.cs:93` |
| Catch-Block ruft `_state.FailAsync` und broadcastet, statt nur zu loggen | `QueueService.cs:349-352` |
| Setup-Block (Zeilen 218238) in den bestehenden `try` ziehen, damit `MarkFailed` greift | `TaskRunner.ContinueAsync` |
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
| `TaskUpdated` nach dem Insert broadcasten | `Runner/InteractiveLaunchSpecService.cs:447` |
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
### 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 12 s zum Laden braucht, würde ein periodischer Abgleich alles verschlimmern.
Ein Timer (35 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, 90110 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.