# 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`. - **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder. - **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 four-way action radio group (Ping / Fill My Day / Tickets + plan 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` or `ImportTickets`. The Tickets tab (`TicketSettingsTabViewModel`) also holds the fallback-list dropdown for the ticket import; it reads the lists straight from the DB and only writes the fallback back after a successful load. | | `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. **Doubles as the folder editor:** `IsFolder` collapses the body to name + icon, and it's reached either via the create-only "create a folder instead" toggle (`CanToggleFolder`, set by `BeginLoad`) or via `BeginLoadFolder` on an existing folder. There is no list↔folder conversion. Creating a folder writes a `list_folders` row and **deletes the list row the create flow pre-created**, reporting it as `Deleted` so the caller does a full reload. | | `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. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. | 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`. `CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is markdown under `Assets/CheatSheets/*.md` as **EmbeddedResource** (not `AvaloniaResource` — `AssetLoader` throws without an initialized Avalonia app, which would make the VM unconstructible in tests), rendered through `MarkdownView`, with a `Topmost` pin so it can sit docked beside the shell. A new sheet = a new `.md` file + its key in `CheatSheetsViewModel.Keys` (+ a menu entry if it should be directly reachable). `MarkdownView` has no table/fenced-code support, so sheets are bullet lists with inline code — rendered via its opt-in `CommandRows` property, which turns a `` `command` — description `` bullet into a mono chip + copy button + description and wraps each `##` section in a collapsible `Expander` (blocks are added to the open section's panel, not to the host). The flag is opt-in because the other three `MarkdownView` call sites (prompt-file viewer, weekly report, task-description preview) render prose where copy buttons would be noise. ## 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 `ScrollSync`. 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 `` 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:` 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.HeaderActions` is the titlebar slot left of maximise/close — for a window-level action that should stay visible without costing a body row (currently only the usage monitor's refresh). `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 `` 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`, not ``) — 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).