Files
ClaudeDo/src/ClaudeDo.Ui/CLAUDE.md
T

23 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.
    • Folders: the sidebar's top level is one mixed collection, TopLevel (ObservableCollection<object>: 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 DataTemplates 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.

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

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 <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.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 <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.
  • 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).