DetailsIsland.BindAsync churn (~1900 binds/day, 91-96% cancelled) was not a UI reactivity bug: OperationTiming.Shared is a static singleton hardcoded to ~/.todo-app/logs/operation-timing.ndjson, and Ui.Tests/Worker.Tests both construct real DetailsIslandViewModel/TasksIslandViewModel instances that call Shared.Record directly. Every dotnet test run appended 50-100 lines straight into the live app's log — pid-burst analysis showed 13 distinct test-run pids in the ":?" bucket (tests never pass a source) plus a no-suffix bucket with no pid field at all (pre-dating the pid-per-line feature). Real user-driven binds that day: 4. Made Shared settable and added a [ModuleInitializer]-based TestSetup in both test projects that redirects it to a per-process temp file before any test runs.
16 KiB
ClaudeDo.Ui
Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
Deeper detail: review-merge (diff stack + conflict resolver) · conpty-sessions (Mission Control tiles) · usage-monitoring (usage pill + modal).
Pattern
MVVM with CommunityToolkit.Mvvm source generators:
[ObservableProperty]for bindable properties,[RelayCommand]for commands- All ViewModels inherit
ViewModelBase(extendsObservableObject) - 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;FlashFooterErrorsurfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (ShowLists/ShowDetailsby window width),PrimeStatusflash, the modal openers, andRestartWorkerAsync/CheckForUpdatesAsync. HostsUpdateCheckService. - 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. RaisesNotesRequested/PrepRequestedfor the shell. - DetailsIslandViewModel — the detail pane for a bound
TaskRowViewModel. Owns live-log streaming (LogviaStreamLineFormatter), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (output/git/session), child surfacing (ChildOutcomes+ChildrenNeedingAttentiondrive an attention band on the Session tab), attachments, and the modesIsNotesMode/IsPrepMode/computedIsTaskDetailVisible. Failures raiseErrorReported, wired by the shell intoFlashFooterError.- 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 byCanReplyToRoadblockonLatestRunSessionId) resumes the session over the sameContinueTaskAsynctransport asContinueCommand, but with the user's own text. - Attachments write directly via
new AttachmentStore()+new TaskAttachmentRepository(ctx);ComposedPreviewincludes attachment paths.
- Three concerns are extracted into section VMs exposed as properties:
- TaskRowViewModel / ListNavItemViewModel — lightweight display VMs. Task row: status, planning phase, parent/blocked links, roadblock count, computed
IsDraft/IsPlanned/IsChild/IsPlanningParent/CanRefine, plusIsManual(→ MANUAL badge; suppressesCanSendToQueue/CanRefine/CanOpenPlanningSession) andHasInteractiveSession(→ 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
UsagePillin both the footer and the Mission Control header → usage-monitoring.
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. |
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. |
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. |
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.
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 tracksWorkerHub— treatsrc/ClaudeDo.Worker/Hub/WorkerHub.csas the canonical method list rather than duplicating it here. Events mirrorHubBroadcaster. Lifecycle (StartAsync/StopAsync) and a few admin methods live only on the concreteWorkerClient. - INotesApi / WorkerNotesApi — daily-note CRUD; UI DTO
DailyNoteDto(Id, Date, Text, SortOrder). - IPrimeScheduleApi — prime-schedule CRUD.
- UpdateCheckService — polls releases;
LastCheckStatus/LatestVersion/CheckNowAsyncfeed the shell's update banner. - InheritanceResolver — resolves the task → list → global override chain to
(value, source)for the inherited badges. - OperationTiming — NDJSON sink (
~/.todo-app/logs/operation-timing.ndjson, one rolled.1at 4 MB) behind every hub invoke and bulk DB path; each line carriespidbecause app restarts interleave in one file. Successful calls under 25 ms are dropped, failed/cancelled ones always land — a 1 ms cancelledBindAsyncis churn signal, not noise.DetailsIsland.BindAsync:<source>carries the selection trigger (TasksIslandViewModel.SelectionSource, set viaSelectFrom— never assignSelectedTaskdirectly).Sharedis a settable static singleton on purpose —Ui.Tests/Worker.Testseach carry aTestSetupmodule initializer that redirects it to a temp file before any test runs, because both projects construct real ViewModels (DetailsIslandViewModel,TasksIslandViewModel) that callShared.Recorddirectly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900BindAsynclines, 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, strike/italic/opacity toggles, null→bool,
uppercase.
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
PathIconfills its geometry. Line-art/stroke icons must be authored as filled geometry or rendered with a strokedPath(e.g.Icon.PlanDayvia thePath.plan-iconstyle). A pure stroke path in aPathIconis invisible.NumericUpDown.Valueisdecimal?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-nullableint/decimal, that null throwsInvalidCastException. Either bind adecimal?property (asAgentConfigEditorViewModel.MaxTurnsdoes) or addConverter={StaticResource KeepLastNumber}, which drops the null viaBindingOperations.DoNothing.ScrollViewer.Paddingis 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 afterScrollToEnd(). Put the inset on the content (Marginon the innerItemsControl/StackPanel) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible.ListBoxdoes not have this trap — its Fluent control-theme template forwardsPaddingontoPART_ItemsPresenter's margin (content side), not the rawScrollViewer.Padding; verified headlessly thatListBox.Padding="10,4"and aMargin="10,4"on theItemsPanelTemplatepanel 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.- Never bind bare punctuation gestures. Window key bindings live on
MainWindow(Ctrl+Ksearch,Ctrl+Nadd-task).OemQuestiononce held search focus and silently swallowed#app-wide on a German layout. FocusClearing's Escape handler is scoped toMainWindow(AddClassHandler<MainWindow>, not<TopLevel>) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separateWindowinstances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are inMissionControlWindow, 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.
- 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 ownDataContextfrom aStyledProperty. 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 throwsInvalidCastException. Scope the DataContext to an inner panel instead (DataContext="{Binding #Root.Status}", seeOperationIndicator.axaml). Corollary: the inner panel's DataContext is then null whenever the property is null, soIsVisible="{Binding ShowIndicator}"can't resolve and falls back to the property defaulttrue— a permanent label-less spinner plus the stalled hint.OperationIndicatortherefore collapses itself in code-behind whenStatusis null; wire every call site withStatus=, neverDataContext=. - Every
IWorkerClientcall in a[RelayCommand]runs through anOperationStatus(Services/OperationStatus.cs), shown via theOperationIndicatorcontrol — never a handcrafted spinner. Without it the spinnerStackPanelfromMergeModalView.axamlgets rebuilt at every call site, andIsBusyflags lock the button but show nothing.Begin/Report/Disposemust run on the UI thread — only the tick is marshalled, so aTask.Run-wrapped call leaves the bindings updating off-thread (the startup update check did this). A[RelayCommand]satisfies this on its own. Separately,Endretires the generation so tick work that was posted while the dispatcher was blocked can't drain afterwards and setShowIndicatorback 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. SessionTerminalViewis the reusable log terminal (StyledPropertiesEntries,Label,IsRunning,IsDone,IsFailed) — used for both the taskLogand the prepPrepLog.DetailsIslandViewis a pane-wide drag-and-drop file target (DragDrop.AllowDrop, Avalonia 12DataFormat.File) with a "Drop to attach" overlay;DescriptionStepsCardshows the attachments list, an "Add file…" picker, and an explicitDropStatusline. Keys use thedetails.attachments.*locale namespace (en + de).