Files
ClaudeDo/src/ClaudeDo.Ui/CLAUDE.md
T
Mika Kuns c1ec05a437 fix(ui): stop tests from polluting the live operation-timing.ndjson
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.
2026-08-13 08:35:09 +02:00

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 (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.

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 tracks WorkerHub — treat src/ClaudeDo.Worker/Hub/WorkerHub.cs as the canonical method list rather than duplicating it here. 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 (~/.todo-app/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. 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 purposeUi.Tests/Worker.Tests each carry a TestSetup module initializer that redirects it to a temp file before any test runs, 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, 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

  • PathIcon fills its geometry. Line-art/stroke icons must be authored as filled geometry or rendered with a stroked Path (e.g. Icon.PlanDay via the Path.plan-icon style). A pure stroke path in a PathIcon is invisible.
  • 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.
  • 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.
  • 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).