# ClaudeDo.Ui

Avalonia UI layer: views, viewmodels, converters, and the SignalR client.

Deeper detail: [review-merge](../../docs/explore-notes/review-merge.md) (diff stack + conflict
resolver) · [conpty-sessions](../../docs/explore-notes/conpty-sessions.md) (Mission Control
tiles) · [usage-monitoring](../../docs/explore-notes/usage-monitoring.md) (usage pill + modal).

## Pattern

MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties, `[RelayCommand]` for commands
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
- All views use compiled bindings (`x:DataType`)

## Layout: Islands

`MainWindow` hosts three "islands" (lists | tasks | details). There is **no**
MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is
`IslandsShellViewModel`, and task/list editing happens inline in the islands.

```
ViewModels/
  IslandsShellViewModel.cs  — root coordinator
  Islands/    — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
                NotesEditor, MergePreviewPresenter
  Agent/      — AgentConfigEditorViewModel (scope-parameterized: List | Task)
  Modals/     — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
                RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
                WorkerConnection, WorktreesOverview, UnifiedDiffParser
  Conflicts/  — ConflictResolverViewModel + ConflictModels
Views/          — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
                  DescriptionStepsCard, WorkConsole; plus SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
                  InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
Design/         — Tokens.axaml (design tokens; merged before styles)
                  + IslandStyles.axaml (component styles + the filled icon geometry library)
```

## Core ViewModels

- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
  - Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
  - The ROADBLOCK card's reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) resumes the session over the same `ContinueTaskAsync` transport as `ContinueCommand`, but with the user's own text.
  - Attachments write directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`; `ComposedPreview` includes attachment paths.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs. Task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`, plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping jumps to that Mission Control pane). List row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`.
- **NotesEditorViewModel** — day navigator + bullet CRUD via `INotesApi`.
- **UsagePillViewModel** — one shared instance backs the `UsagePill` in both the footer and the Mission Control header → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).

## Modal VMs

| VM | Notes |
|---|---|
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`. |
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. |

Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.

## Diff & Conflicts

`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
read-only viewer, Files and Planning modes) render through `DiffAlignment` (pure — pairs diff
lines into side-by-side rows and computes word-diff spans) and `DiffTextView` (AvaloniaEdit +
TextMate highlighting, unified/split layout, optional line wrap, synced scrolling). The
split/wrap toggles persist to `ui.config.json` via `AppSettings`.
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
single-task and planning unit-merge conflicts. Full detail →
[review-merge](../../docs/explore-notes/review-merge.md).

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
new editor boilerplate there rather than copying it a third time.

## Services

- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. The wire DTOs are **not** declared here: they live once in `ClaudeDo.Data/Wire.cs` and reach both sides via a `<Using Include="ClaudeDo.Data.Wire" />` item in the csproj (Ui, Worker, and both test projects), so a hub signature change is a compile error instead of a silently dropped JSON field. Add new ones there, never a local copy. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi / WorkerNotesApi** — daily-note CRUD; UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **OperationTiming** — NDJSON sink (`~/.claudeDo/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Off by default — `Shared` only writes when the environment variable `CLAUDEDO_OP_TIMING=1` is set at process start (read once statically, no per-call overhead). Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose** — `Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs (still writing — the kill switch only gates `Shared`'s default, not the constructor), because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4).
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.

## Converters

In `Converters/` — grep rather than list: log-level brush, dot brush,
status equality, icon key, `KeepLastNumber`. The one-expression ones
(strike/italic/opacity toggles, uppercase, not-a-header-row) share
`SimpleConverters.cs`; put new trivial ones there instead of a new file.
There is no null→bool converter — bind
`Converter={x:Static conv:ObjectConverters.IsNotNull}` (Avalonia ships it).

## Dialog Pattern

Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
sets the result on save/cancel, the caller awaits the TCS.

`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
`OffScreenMargin` so a maximised extended-client-area window isn't clipped at the edges.

## Gotchas

- **Never set `Foreground`/`Stroke` on an icon inline.** A local value outranks every style, so the button's hover/checked/disabled states can't reach the icon and it looks dead on hover — this is what broke the icons that were "fixed" to the right colour inline. Colour is picked with a class; the single `ICON PAINTING` block in `IslandStyles.axaml` owns all states (`.danger`, `.star-btn.on`, `.active`, `:checked`, `:disabled`). Avalonia has no specificity — that block's **declaration order** is the mechanism, so add new variants in the right section, not at the end.
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered as `<Path Classes="icon-stroke">` inside a `Viewbox` (e.g. `Icon.PlanDay`, `Icon.GitBranch`). A pure stroke path in a `PathIcon` is **invisible**. `Path.icon-stroke` takes the same colour classes/states as `PathIcon`.
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
- **Re-templating a `TabControl` must keep `PART_ItemsPresenter`.** `TabControl.SelectedContent` is resolved via `ContainerFromIndex`, and containers are only realized by the `ItemsPresenter`. A template that renders just `PART_SelectedContentHost` (to hide the tab strip) leaves `SelectedContent` permanently `null` → an empty body. Keep the presenter with `IsVisible="False"`; that still realizes containers (verified headlessly, Avalonia 12.0.4). This broke the whole Settings modal body in 56c3719e.
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
- Context menus exist on both list and task rows; right-click selects before opening the menu.
- "Run Now" CanExecute re-evaluates when worker connection state changes.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
- **Never set a `UserControl`'s own `DataContext` from a `StyledProperty`.** The property binding at the call site (`Status="{Binding RejectOp}"`) resolves *against that control's DataContext* — overwriting it re-targets the binding at the new value and the compiled getter throws `InvalidCastException`. Scope the DataContext to an inner panel instead (`DataContext="{Binding #Root.Status}"`, see `OperationIndicator.axaml`). Corollary: **the inner panel's DataContext is then null whenever the property is null**, so `IsVisible="{Binding ShowIndicator}"` can't resolve and falls back to the property default `true` — a permanent label-less spinner *plus* the stalled hint. `OperationIndicator` therefore collapses itself in code-behind when `Status` is null; wire every call site with `Status=`, never `DataContext=`.
- **Every `IWorkerClient` call in a `[RelayCommand]` runs through an `OperationStatus` (`Services/OperationStatus.cs`), shown via the `OperationIndicator` control** — never a handcrafted spinner. Without it the spinner `StackPanel` from `MergeModalView.axaml` gets rebuilt at every call site, and `IsBusy` flags lock the button but show nothing. **`Begin`/`Report`/`Dispose` must run on the UI thread** — only the tick is marshalled, so a `Task.Run`-wrapped call leaves the bindings updating off-thread (the startup update check did this). A `[RelayCommand]` satisfies this on its own. Separately, **`End` retires the generation** so tick work that was posted while the dispatcher was blocked can't drain afterwards and set `ShowIndicator` back to true on a finished operation — that was the real frozen-spinner bug, and the enabling condition (~2.4s blocked UI thread during window construction) is still there.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
